,
,
.
+var htmlTagRe = regexp.MustCompile(`<[a-zA-Z][^>]*>`)
+
+// isLikelyHTML reports whether s appears to contain HTML markup.
+func isLikelyHTML(s string) bool {
+ return htmlTagRe.MatchString(s)
+}
+
+// plainTextToHTML converts plain text to a simple HTML representation.
+// It escapes HTML entities and converts newlines to
tags.
+func plainTextToHTML(s string) string {
+ s = html.EscapeString(s)
+ s = strings.ReplaceAll(s, "\n", "
\n")
+ return s
+}
+
func sendGmailBatches(ctx context.Context, svc *gmail.Service, opts sendMessageOptions, batches []sendBatch) ([]sendResult, error) {
reply := replyInfo{}
if opts.ReplyInfo != nil {
diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go
new file mode 100644
index 00000000..b67bdee3
--- /dev/null
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -0,0 +1,704 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/gmail/v1"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+func TestGmailSendCmd_SignaturePlain(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ var gotRaw string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
+ switch {
+ case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAsEmail": "a@b.com",
+ "signature": "Sig
",
+ "verificationStatus": "accepted",
+ })
+ return
+ case r.Method == http.MethodPost && path == "/users/me/messages/send":
+ var payload struct {
+ Raw string `json:"raw"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode payload: %v", err)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ gotRaw = string(decoded)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ cmd := &GmailSendCmd{
+ To: "a@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ Signature: true,
+ }
+ if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotRaw == "" {
+ t.Fatalf("expected raw message")
+ }
+ if !strings.Contains(gotRaw, "Body\r\n\r\n--\r\nSig") {
+ t.Fatalf("expected signature in body, got: %q", gotRaw)
+ }
+}
+
+func TestGmailSendCmd_SignatureHTML(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ var gotRaw string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
+ switch {
+ case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAsEmail": "a@b.com",
+ "signature": "Sig
",
+ "verificationStatus": "accepted",
+ })
+ return
+ case r.Method == http.MethodPost && path == "/users/me/messages/send":
+ var payload struct {
+ Raw string `json:"raw"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode payload: %v", err)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ gotRaw = string(decoded)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ cmd := &GmailSendCmd{
+ To: "a@example.com",
+ Subject: "Hello",
+ BodyHTML: "Hello
",
+ Signature: true,
+ }
+ if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotRaw == "" {
+ t.Fatalf("expected raw message")
+ }
+ if !strings.Contains(gotRaw, "Hello
\r\n\r\n") {
+ t.Fatalf("expected HTML signature wrapper, got: %q", gotRaw)
+ }
+}
+
+func TestGmailSendCmd_SignatureFileMissing(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication())
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ cmd := &GmailSendCmd{
+ To: "a@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ SignatureFile: "/no/such/signature.txt",
+ }
+ if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err == nil || !strings.Contains(err.Error(), "signature file") {
+ t.Fatalf("expected signature file error, got: %v", err)
+ }
+}
+
+func TestGmailSendCmd_SignatureFileTooLarge(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication())
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ // Create a temp file larger than maxSignatureFileSize (1 MB).
+ tmp := filepath.Join(t.TempDir(), "big_sig.html")
+ data := make([]byte, maxSignatureFileSize+1)
+ if writeErr := os.WriteFile(tmp, data, 0o600); writeErr != nil {
+ t.Fatalf("write temp file: %v", writeErr)
+ }
+
+ cmd := &GmailSendCmd{
+ To: "a@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ SignatureFile: tmp,
+ }
+ err = cmd.Run(ctx, &RootFlags{Account: "a@b.com"})
+ if err == nil {
+ t.Fatal("expected error for oversized signature file, got nil")
+ }
+ if !strings.Contains(err.Error(), "signature file too large") {
+ t.Fatalf("expected 'signature file too large' error, got: %v", err)
+ }
+}
+
+func TestIsLikelyHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want bool
+ }{
+ {name: "empty", in: "", want: false},
+ {name: "plain text", in: "Hello world", want: false},
+ {name: "angle brackets no tag", in: "1 < 2 > 0", want: false},
+ {name: "div tag", in: "hello
", want: true},
+ {name: "br tag", in: "line
break", want: true},
+ {name: "p tag", in: "paragraph
", want: true},
+ {name: "anchor tag", in: `link`, want: true},
+ {name: "self-closing br", in: "text
more", want: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isLikelyHTML(tt.in)
+ if got != tt.want {
+ t.Errorf("isLikelyHTML(%q) = %v, want %v", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPlainTextToHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {name: "empty", in: "", want: ""},
+ {name: "simple text", in: "Hello", want: "Hello"},
+ {name: "newlines", in: "Line1\nLine2\nLine3", want: "Line1
\nLine2
\nLine3"},
+ {name: "escapes ampersand", in: "A & B", want: "A & B"},
+ {name: "escapes angle brackets", in: "1 < 2 > 0", want: "1 < 2 > 0"},
+ {name: "combined escapes and newlines", in: "A & B\nC < D", want: "A & B
\nC < D"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := plainTextToHTML(tt.in)
+ if got != tt.want {
+ t.Errorf("plainTextToHTML(%q)\ngot: %q\nwant: %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSignatureFileContentType_PlainText(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ var gotRaw string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
+ switch {
+ case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAsEmail": "a@b.com",
+ "verificationStatus": "accepted",
+ })
+ return
+ case r.Method == http.MethodPost && path == "/users/me/messages/send":
+ var payload struct {
+ Raw string `json:"raw"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode payload: %v", err)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ gotRaw = string(decoded)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ // Write a plain-text signature file with special characters and newlines.
+ tmp := filepath.Join(t.TempDir(), "sig.txt")
+ if writeErr := os.WriteFile(tmp, []byte("Best & regards\nJohn Doe\n© 2024"), 0o600); writeErr != nil {
+ t.Fatalf("write temp file: %v", writeErr)
+ }
+
+ cmd := &GmailSendCmd{
+ To: "a@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ BodyHTML: "
Body
",
+ SignatureFile: tmp,
+ }
+ if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotRaw == "" {
+ t.Fatal("expected raw message")
+ }
+
+ // Plain part should contain the file verbatim (no HTML escaping).
+ if !strings.Contains(gotRaw, "Best & regards") {
+ t.Errorf("plain body should contain original plain text, got: %q", gotRaw)
+ }
+
+ // HTML part should have escaped entities and
for newlines.
+ if !strings.Contains(gotRaw, "Best & regards
") {
+ t.Errorf("HTML body should contain escaped entities and
, got: %q", gotRaw)
+ }
+ if !strings.Contains(gotRaw, "© 2024") {
+ t.Errorf("HTML body should contain the copyright line, got: %q", gotRaw)
+ }
+}
+
+func TestSignatureFileContentType_HTML(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ var gotRaw string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
+ switch {
+ case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAsEmail": "a@b.com",
+ "verificationStatus": "accepted",
+ })
+ return
+ case r.Method == http.MethodPost && path == "/users/me/messages/send":
+ var payload struct {
+ Raw string `json:"raw"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode payload: %v", err)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ gotRaw = string(decoded)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ // Write an HTML signature file.
+ tmp := filepath.Join(t.TempDir(), "sig.html")
+ htmlSig := `
John Doe
`
+ if writeErr := os.WriteFile(tmp, []byte(htmlSig), 0o600); writeErr != nil {
+ t.Fatalf("write temp file: %v", writeErr)
+ }
+
+ cmd := &GmailSendCmd{
+ To: "a@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ BodyHTML: "
Body
",
+ SignatureFile: tmp,
+ }
+ if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotRaw == "" {
+ t.Fatal("expected raw message")
+ }
+
+ // HTML part should contain the file content verbatim (as-is).
+ if !strings.Contains(gotRaw, htmlSig) {
+ t.Errorf("HTML body should contain original HTML signature, got: %q", gotRaw)
+ }
+
+ // Plain part should have gone through signatureHTMLToPlain.
+ // signatureHTMLToPlain converts
tags to "text (URL)" format.
+ if !strings.Contains(gotRaw, "My Site (https://example.com)") {
+ t.Errorf("plain body should contain converted anchor tag, got: %q", gotRaw)
+ }
+ if !strings.Contains(gotRaw, "John Doe") {
+ t.Errorf("plain body should contain text from div, got: %q", gotRaw)
+ }
+}
+
+func TestAppendSignaturePlain(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ signature string
+ want string
+ }{
+ {name: "normal case", body: "Hello", signature: "-- John", want: "Hello\n\n--\n-- John"},
+ {name: "empty signature", body: "Hello", signature: "", want: "Hello"},
+ {name: "whitespace-only signature", body: "Hello", signature: " ", want: "Hello"},
+ {name: "empty body with signature", body: "", signature: "Sig", want: "\n\n--\nSig"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := appendSignaturePlain(tt.body, tt.signature)
+ if got != tt.want {
+ t.Errorf("appendSignaturePlain(%q, %q)\ngot: %q\nwant: %q", tt.body, tt.signature, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAppendSignatureHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ signature string
+ want string
+ }{
+ {
+ name: "normal case",
+ body: "Hi
",
+ signature: "Sig
",
+ want: "Hi
\n\n" + ``,
+ },
+ {name: "empty signature", body: "Hi
", signature: "", want: "Hi
"},
+ {name: "whitespace-only signature", body: "Hi
", signature: " ", want: "Hi
"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := appendSignatureHTML(tt.body, tt.signature)
+ if got != tt.want {
+ t.Errorf("appendSignatureHTML(%q, %q)\ngot: %q\nwant: %q", tt.body, tt.signature, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestValidateSignatureFlags(t *testing.T) {
+ tests := []struct {
+ name string
+ signature bool
+ signatureName string
+ signatureFile string
+ wantErr bool
+ }{
+ {name: "no flags set", signature: false, signatureName: "", signatureFile: "", wantErr: false},
+ {name: "only --signature", signature: true, signatureName: "", signatureFile: "", wantErr: false},
+ {name: "only --signature-name", signature: false, signatureName: "alias@example.com", signatureFile: "", wantErr: false},
+ {name: "only --signature-file", signature: false, signatureName: "", signatureFile: "/tmp/sig.html", wantErr: false},
+ {name: "--signature + --signature-name", signature: true, signatureName: "alias@example.com", signatureFile: "", wantErr: true},
+ {name: "--signature + --signature-file", signature: true, signatureName: "", signatureFile: "/tmp/sig.html", wantErr: true},
+ {name: "--signature-name + --signature-file", signature: false, signatureName: "alias@example.com", signatureFile: "/tmp/sig.html", wantErr: true},
+ {name: "all three", signature: true, signatureName: "alias@example.com", signatureFile: "/tmp/sig.html", wantErr: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateSignatureFlags(tt.signature, tt.signatureName, tt.signatureFile)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateSignatureFlags(%v, %q, %q) error = %v, wantErr = %v",
+ tt.signature, tt.signatureName, tt.signatureFile, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestGmailSendCmd_SignatureNameAlias(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ var gotRaw string
+ var gotSendAsGetPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
+ switch {
+ case r.Method == http.MethodGet && strings.HasPrefix(path, "/users/me/settings/sendAs/"):
+ gotSendAsGetPath = path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAsEmail": "alias@example.com",
+ "signature": "Alias Sig
",
+ "verificationStatus": "accepted",
+ })
+ return
+ case r.Method == http.MethodPost && path == "/users/me/messages/send":
+ var payload struct {
+ Raw string `json:"raw"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode payload: %v", err)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ gotRaw = string(decoded)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+
+ cmd := &GmailSendCmd{
+ To: "recipient@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ SignatureName: "alias@example.com",
+ }
+ if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ // Verify the SendAs.Get call used alias@example.com, not the account email a@b.com.
+ wantPath := "/users/me/settings/sendAs/alias@example.com"
+ if gotSendAsGetPath != wantPath {
+ t.Errorf("expected SendAs.Get path %q, got %q", wantPath, gotSendAsGetPath)
+ }
+
+ if gotRaw == "" {
+ t.Fatal("expected raw message")
+ }
+ // The signature HTML should be converted to plain text "Alias Sig" and appended.
+ if !strings.Contains(gotRaw, "Body\r\n\r\n--\r\nAlias Sig") {
+ t.Errorf("expected alias signature in plain body, got: %q", gotRaw)
+ }
+}
+
+func TestSignatureHTMLToPlain(t *testing.T) {
+ tests := []struct {
+ name string
+ html string
+ want string
+ }{
+ {
+ name: "empty string",
+ html: "",
+ want: "",
+ },
+ {
+ name: "whitespace only",
+ html: " \t\n ",
+ want: "",
+ },
+ {
+ name: "simple div",
+ html: "Sig
",
+ want: "Sig",
+ },
+ {
+ name: "p tags with line breaks",
+ html: "Line1
Line2
",
+ want: "Line1\nLine2",
+ },
+ {
+ name: "anchor tag converted to text with URL",
+ html: `My Site`,
+ want: "My Site (https://example.com)",
+ },
+ {
+ name: "anchor tag where text equals URL",
+ html: `
https://example.com`,
+ want: "https://example.com",
+ },
+ {
+ name: "anchor tag with no text",
+ html: `
`,
+ want: "https://example.com",
+ },
+ {
+ name: "table with rows",
+ html: "
",
+ want: "Row1\nRow2",
+ },
+ {
+ name: "br tag",
+ html: "Line1
Line2",
+ want: "Line1\nLine2",
+ },
+ {
+ name: "br self-closing no space",
+ html: "Line1
Line2",
+ want: "Line1\nLine2",
+ },
+ {
+ name: "br self-closing with space",
+ html: "Line1
Line2",
+ want: "Line1\nLine2",
+ },
+ {
+ name: "multiple consecutive newlines collapsed",
+ html: "
A
B
",
+ want: "A\n\nB",
+ },
+ {
+ name: "mixed tags",
+ html: `
John Doe
555-1234
`,
+ want: "John Doe\nexample.com (https://example.com)\n555-1234",
+ },
+ {
+ name: "trailing whitespace trimmed",
+ html: "
Hello
",
+ want: "Hello",
+ },
+ {
+ name: "plain text passthrough",
+ html: "Just plain text",
+ want: "Just plain text",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := signatureHTMLToPlain(tt.html)
+ if got != tt.want {
+ t.Errorf("signatureHTMLToPlain(%q)\ngot: %q\nwant: %q", tt.html, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/gmail_send_validation_more_test.go b/internal/cmd/gmail_send_validation_more_test.go
index eda2e09b..e9c89ae8 100644
--- a/internal/cmd/gmail_send_validation_more_test.go
+++ b/internal/cmd/gmail_send_validation_more_test.go
@@ -30,6 +30,7 @@ func TestGmailSendCmd_ValidationErrors(t *testing.T) {
{To: "a@b.com", Body: "B"},
{To: "a@b.com", Subject: "S"},
{To: "a@b.com", Subject: "S", Body: "B", TrackSplit: true},
+ {To: "a@b.com", Subject: "S", Body: "B", Signature: true, SignatureName: "alias@example.com"},
}
for _, cmd := range cases {
diff --git a/internal/cmd/gmail_thread.go b/internal/cmd/gmail_thread.go
index 52ccac96..1dbce5d6 100644
--- a/internal/cmd/gmail_thread.go
+++ b/internal/cmd/gmail_thread.go
@@ -573,7 +573,7 @@ func downloadAttachment(ctx context.Context, svc *gmail.Service, messageID strin
// Sanitize filename to prevent path traversal attacks
safeFilename := filepath.Base(a.Filename)
if safeFilename == "" || safeFilename == "." || safeFilename == ".." {
- safeFilename = "attachment"
+ safeFilename = defaultAttachmentName
}
filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, safeFilename)
outPath := filepath.Join(dir, filename)
diff --git a/internal/cmd/gmail_thread_helpers_test.go b/internal/cmd/gmail_thread_helpers_test.go
index 73443d6e..1a80e520 100644
--- a/internal/cmd/gmail_thread_helpers_test.go
+++ b/internal/cmd/gmail_thread_helpers_test.go
@@ -18,7 +18,7 @@ func TestStripHTMLTags_More(t *testing.T) {
}
}
-func TestFormatBytes(t *testing.T) {
+func TestFormatBytes_Helpers(t *testing.T) {
if got := formatBytes(500); got != "500 B" {
t.Fatalf("unexpected bytes format: %q", got)
}
@@ -66,7 +66,7 @@ func TestCollectAttachments_More(t *testing.T) {
}
}
-func TestAttachmentLine(t *testing.T) {
+func TestAttachmentLine_Helpers(t *testing.T) {
att := attachmentOutput{
Filename: "file.txt",
Size: 12,
@@ -186,7 +186,7 @@ func TestDecodeBodyCharset_ISO88591(t *testing.T) {
}
}
-func TestMimeTypeMatches(t *testing.T) {
+func TestMimeTypeMatches_Helpers(t *testing.T) {
if !mimeTypeMatches("Text/Plain; charset=UTF-8", "text/plain") {
t.Fatalf("expected mime match")
}
diff --git a/internal/cmd/gmail_thread_test.go b/internal/cmd/gmail_thread_test.go
index 57c0528d..60be0b80 100644
--- a/internal/cmd/gmail_thread_test.go
+++ b/internal/cmd/gmail_thread_test.go
@@ -68,7 +68,7 @@ func TestBestBodyText_MimeTypeWithParams(t *testing.T) {
}
}
-func TestDecodeBase64URL(t *testing.T) {
+func TestDecodeBase64URL_Thread(t *testing.T) {
got, err := decodeBase64URL(base64.RawURLEncoding.EncodeToString([]byte("ok")))
if err != nil {
t.Fatalf("err: %v", err)
@@ -88,7 +88,7 @@ func TestDecodeBase64URL(t *testing.T) {
}
}
-func TestStripHTMLTags(t *testing.T) {
+func TestStripHTMLTags_Thread(t *testing.T) {
tests := []struct {
name string
input string
diff --git a/internal/cmd/gmail_url_test.go b/internal/cmd/gmail_url_test.go
index 9b269cb5..6dbbd167 100644
--- a/internal/cmd/gmail_url_test.go
+++ b/internal/cmd/gmail_url_test.go
@@ -11,7 +11,7 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
-func TestGmailURLCmd_JSON(t *testing.T) {
+func TestGmailURLCmd_JSON_URL(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
@@ -35,7 +35,7 @@ func TestGmailURLCmd_JSON(t *testing.T) {
}
}
-func TestGmailURLCmd_Text(t *testing.T) {
+func TestGmailURLCmd_Text_URL(t *testing.T) {
cmd := GmailURLCmd{ThreadIDs: []string{"t1"}}
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go
index fdeafbc1..4121ba0b 100644
--- a/internal/cmd/gmail_watch_cmds.go
+++ b/internal/cmd/gmail_watch_cmds.go
@@ -191,20 +191,21 @@ func (c *GmailWatchStopCmd) Run(ctx context.Context, flags *RootFlags) error {
}
type GmailWatchServeCmd struct {
- Bind string `name:"bind" help:"Bind address" default:"127.0.0.1"`
- Port int `name:"port" help:"Listen port" default:"8788"`
- Path string `name:"path" help:"Push handler path" default:"/gmail-pubsub"`
- Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: local"`
- Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"`
- VerifyOIDC bool `name:"verify-oidc" help:"Verify Pub/Sub OIDC tokens"`
- OIDCEmail string `name:"oidc-email" help:"Expected service account email"`
- OIDCAudience string `name:"oidc-audience" help:"Expected OIDC audience"`
- SharedToken string `name:"token" help:"Shared token for x-gog-token or ?token="`
- HookURL string `name:"hook-url" help:"Webhook URL to forward messages"`
- HookToken string `name:"hook-token" help:"Webhook bearer token"`
- IncludeBody bool `name:"include-body" help:"Include text/plain body in hook payload"`
- MaxBytes int `name:"max-bytes" help:"Max bytes of body to include" default:"20000"`
- SaveHook bool `name:"save-hook" help:"Persist hook settings to watch state"`
+ Bind string `name:"bind" help:"Bind address" default:"127.0.0.1"`
+ Port int `name:"port" help:"Listen port" default:"8788"`
+ Path string `name:"path" help:"Push handler path" default:"/gmail-pubsub"`
+ Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: local"`
+ Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"`
+ VerifyOIDC bool `name:"verify-oidc" help:"Verify Pub/Sub OIDC tokens"`
+ OIDCEmail string `name:"oidc-email" help:"Expected service account email"`
+ OIDCAudience string `name:"oidc-audience" help:"Expected OIDC audience"`
+ SharedToken string `name:"token" help:"Shared token for x-gog-token or ?token="`
+ HookURL string `name:"hook-url" help:"Webhook URL to forward messages"`
+ HookToken string `name:"hook-token" help:"Webhook bearer token"`
+ IncludeBody bool `name:"include-body" help:"Include text/plain body in hook payload"`
+ MaxBytes int `name:"max-bytes" help:"Max bytes of body to include" default:"20000"`
+ HistoryTypes []string `name:"history-types" help:"History types to include (repeatable, comma-separated: messageAdded,messageDeleted,labelAdded,labelRemoved). Default: messageAdded"`
+ SaveHook bool `name:"save-hook" help:"Persist hook settings to watch state"`
}
func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
@@ -234,6 +235,11 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags
return err
}
+ historyTypes, err := parseHistoryTypes(c.HistoryTypes)
+ if err != nil {
+ return err
+ }
+
store, err := loadGmailWatchStore(account)
if err != nil {
return err
@@ -297,6 +303,7 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags
HookTimeout: defaultHookRequestTimeoutSec * time.Second,
HistoryMax: defaultHistoryMaxResults,
ResyncMax: defaultHistoryResyncMax,
+ HistoryTypes: historyTypes,
AllowNoHook: hook == nil,
IncludeBody: includeBody,
MaxBodyBytes: maxBytes,
diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go
index f58de88b..bfcc61aa 100644
--- a/internal/cmd/gmail_watch_server.go
+++ b/internal/cmd/gmail_watch_server.go
@@ -177,7 +177,9 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
}
historyCall := svc.Users.History.List("me").StartHistoryId(startID).MaxResults(s.cfg.HistoryMax)
- historyCall.HistoryTypes("messageAdded")
+ if len(s.cfg.HistoryTypes) > 0 {
+ historyCall.HistoryTypes(s.cfg.HistoryTypes...)
+ }
historyResp, err := historyCall.Do()
if err != nil {
@@ -187,38 +189,36 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
return nil, err
}
- messageIDs := collectHistoryMessageIDs(historyResp)
- msgs, err := s.fetchMessages(ctx, svc, messageIDs)
- if err != nil {
- return nil, err
- }
-
nextHistoryID := payload.HistoryID
if historyResp != nil && historyResp.HistoryId != 0 {
nextHistoryID = formatHistoryID(historyResp.HistoryId)
}
- if err := store.Update(func(state *gmailWatchState) error {
- shouldUpdate, err := shouldUpdateHistoryID(state.HistoryID, nextHistoryID)
- if err != nil {
- return err
- }
- if shouldUpdate {
- state.HistoryID = nextHistoryID
- }
- if payload.MessageID != "" {
- state.LastPushMessageID = payload.MessageID
+ if len(s.cfg.HistoryTypes) > 0 && (historyResp == nil || len(historyResp.History) == 0) {
+ if updateErr := store.Update(func(state *gmailWatchState) error {
+ return updateStateAfterHistory(state, nextHistoryID, payload.MessageID)
+ }); updateErr != nil {
+ s.warnf("watch: failed to update state: %v", updateErr)
}
- state.UpdatedAtMs = time.Now().UnixMilli()
- return nil
- }); err != nil {
- s.warnf("watch: failed to update state: %v", err)
+ return nil, errNoNewMessages
+ }
+
+ historyIDs := collectHistoryMessageIDs(historyResp)
+ msgs, err := s.fetchMessages(ctx, svc, historyIDs.FetchIDs)
+ if err != nil {
+ return nil, err
+ }
+ if updateErr := store.Update(func(state *gmailWatchState) error {
+ return updateStateAfterHistory(state, nextHistoryID, payload.MessageID)
+ }); updateErr != nil {
+ s.warnf("watch: failed to update state: %v", updateErr)
}
return &gmailHookPayload{
- Source: "gmail",
- Account: s.cfg.Account,
- HistoryID: nextHistoryID,
- Messages: msgs,
+ Source: "gmail",
+ Account: s.cfg.Account,
+ HistoryID: nextHistoryID,
+ Messages: msgs,
+ DeletedMessageIDs: historyIDs.DeletedIDs,
}, nil
}
@@ -238,21 +238,10 @@ func (s *gmailWatchServer) resyncHistory(ctx context.Context, svc *gmail.Service
return nil, err
}
- if err := s.store.Update(func(state *gmailWatchState) error {
- shouldUpdate, err := shouldUpdateHistoryID(state.HistoryID, historyID)
- if err != nil {
- return err
- }
- if shouldUpdate {
- state.HistoryID = historyID
- }
- if messageID != "" {
- state.LastPushMessageID = messageID
- }
- state.UpdatedAtMs = time.Now().UnixMilli()
- return nil
- }); err != nil {
- s.warnf("watch: failed to update state after resync: %v", err)
+ if updateErr := s.store.Update(func(state *gmailWatchState) error {
+ return updateStateAfterHistory(state, historyID, messageID)
+ }); updateErr != nil {
+ s.warnf("watch: failed to update state after resync: %v", updateErr)
}
return &gmailHookPayload{
@@ -441,6 +430,23 @@ func pathMatches(expected, actual string) bool {
return strings.HasPrefix(actual, expected+"/")
}
+// updateStateAfterHistory updates the stored state with the new history ID and push message ID.
+// This is a common operation after processing history, whether messages were found or not.
+func updateStateAfterHistory(state *gmailWatchState, historyID, pushMessageID string) error {
+ shouldUpdate, err := shouldUpdateHistoryID(state.HistoryID, historyID)
+ if err != nil {
+ return err
+ }
+ if shouldUpdate {
+ state.HistoryID = historyID
+ }
+ if pushMessageID != "" {
+ state.LastPushMessageID = pushMessageID
+ }
+ state.UpdatedAtMs = time.Now().UnixMilli()
+ return nil
+}
+
func isStaleHistoryError(err error) bool {
var gerr *googleapi.Error
if errors.As(err, &gerr) {
@@ -473,12 +479,58 @@ func isNotFoundAPIError(err error) bool {
return false
}
-func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string {
+// historyMessageIDs holds the result of collecting message IDs from history.
+// FetchIDs contains messages that should be fetched (added, label changes, etc.).
+// DeletedIDs contains messages that were deleted and cannot be fetched.
+type historyMessageIDs struct {
+ FetchIDs []string
+ DeletedIDs []string
+}
+
+func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) historyMessageIDs {
if resp == nil || len(resp.History) == 0 {
- return nil
+ return historyMessageIDs{}
+ }
+ seenFetch := make(map[string]struct{})
+ seenDeleted := make(map[string]struct{})
+ var result historyMessageIDs
+
+ addFetch := func(id string) {
+ if strings.TrimSpace(id) == "" {
+ return
+ }
+ if _, ok := seenFetch[id]; ok {
+ return
+ }
+ // If already marked as deleted, don't add to fetch list
+ if _, ok := seenDeleted[id]; ok {
+ return
+ }
+ seenFetch[id] = struct{}{}
+ result.FetchIDs = append(result.FetchIDs, id)
+ }
+
+ addDeleted := func(id string) {
+ if strings.TrimSpace(id) == "" {
+ return
+ }
+ if _, ok := seenDeleted[id]; ok {
+ return
+ }
+ // Remove from fetch list if previously added
+ if _, ok := seenFetch[id]; ok {
+ delete(seenFetch, id)
+ for i, fid := range result.FetchIDs {
+ if fid == id {
+ result.FetchIDs = append(result.FetchIDs[:i], result.FetchIDs[i+1:]...)
+ break
+ }
+ }
+ }
+ seenDeleted[id] = struct{}{}
+ result.DeletedIDs = append(result.DeletedIDs, id)
}
- seen := make(map[string]struct{})
- out := make([]string, 0)
+
for _, h := range resp.History {
if h == nil {
continue
@@ -487,22 +539,32 @@ func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string {
if added == nil || added.Message == nil || added.Message.Id == "" {
continue
}
- if _, ok := seen[added.Message.Id]; ok {
+ addFetch(added.Message.Id)
+ }
+ for _, deleted := range h.MessagesDeleted {
+ if deleted == nil || deleted.Message == nil || deleted.Message.Id == "" {
continue
}
- seen[added.Message.Id] = struct{}{}
- out = append(out, added.Message.Id)
+ addDeleted(deleted.Message.Id)
}
- for _, msg := range h.Messages {
- if msg == nil || msg.Id == "" {
+ for _, added := range h.LabelsAdded {
+ if added == nil || added.Message == nil || added.Message.Id == "" {
+ continue
+ }
+ addFetch(added.Message.Id)
+ }
+ for _, removed := range h.LabelsRemoved {
+ if removed == nil || removed.Message == nil || removed.Message.Id == "" {
continue
}
- if _, ok := seen[msg.Id]; ok {
+ addFetch(removed.Message.Id)
+ }
+ for _, msg := range h.Messages {
+ if msg == nil || msg.Id == "" {
continue
}
- seen[msg.Id] = struct{}{}
- out = append(out, msg.Id)
+ addFetch(msg.Id)
}
}
- return out
+ return result
}
diff --git a/internal/cmd/gmail_watch_server_helpers_test.go b/internal/cmd/gmail_watch_server_helpers_test.go
index 6bbdc7bd..4e6cdcee 100644
--- a/internal/cmd/gmail_watch_server_helpers_test.go
+++ b/internal/cmd/gmail_watch_server_helpers_test.go
@@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "reflect"
"strings"
"testing"
@@ -50,6 +51,15 @@ func TestCollectHistoryMessageIDs(t *testing.T) {
{Message: &gmail.Message{Id: "m1"}},
nil,
},
+ MessagesDeleted: []*gmail.HistoryMessageDeleted{
+ {Message: &gmail.Message{Id: "m4"}},
+ },
+ LabelsAdded: []*gmail.HistoryLabelAdded{
+ {Message: &gmail.Message{Id: "m5"}},
+ },
+ LabelsRemoved: []*gmail.HistoryLabelRemoved{
+ {Message: &gmail.Message{Id: "m6"}},
+ },
Messages: []*gmail.Message{
{Id: "m2"},
{Id: ""},
@@ -60,10 +70,148 @@ func TestCollectHistoryMessageIDs(t *testing.T) {
},
},
}
- ids := collectHistoryMessageIDs(resp)
- joined := strings.Join(ids, ",")
- if !strings.Contains(joined, "m1") || !strings.Contains(joined, "m2") || !strings.Contains(joined, "m3") {
- t.Fatalf("unexpected ids: %v", ids)
+ result := collectHistoryMessageIDs(resp)
+
+ // Check fetch IDs contain added, labels, and general messages
+ fetchJoined := strings.Join(result.FetchIDs, ",")
+ if !strings.Contains(fetchJoined, "m1") ||
+ !strings.Contains(fetchJoined, "m2") ||
+ !strings.Contains(fetchJoined, "m3") ||
+ !strings.Contains(fetchJoined, "m5") ||
+ !strings.Contains(fetchJoined, "m6") {
+ t.Fatalf("unexpected fetch ids: %v", result.FetchIDs)
+ }
+
+ // Deleted message m4 should NOT be in fetch IDs
+ if strings.Contains(fetchJoined, "m4") {
+ t.Fatalf("deleted message m4 should not be in fetch ids: %v", result.FetchIDs)
+ }
+
+ // Verify exact count: 5 unique fetch IDs (m1, m2, m3, m5, m6)
+ if len(result.FetchIDs) != 5 {
+ t.Fatalf("expected 5 unique fetch ids, got %d: %v", len(result.FetchIDs), result.FetchIDs)
+ }
+
+ // Deleted message m4 should be in deleted IDs
+ if len(result.DeletedIDs) != 1 || result.DeletedIDs[0] != "m4" {
+ t.Fatalf("expected deleted ids [m4], got: %v", result.DeletedIDs)
+ }
+}
+
+func TestCollectHistoryMessageIDs_DeletedRemovesFromFetch(t *testing.T) {
+ // Test that if a message is added then deleted, it ends up only in DeletedIDs
+ resp := &gmail.ListHistoryResponse{
+ History: []*gmail.History{
+ {
+ MessagesAdded: []*gmail.HistoryMessageAdded{
+ {Message: &gmail.Message{Id: "m1"}},
+ },
+ },
+ {
+ MessagesDeleted: []*gmail.HistoryMessageDeleted{
+ {Message: &gmail.Message{Id: "m1"}}, // Same message deleted later
+ },
+ },
+ },
+ }
+ result := collectHistoryMessageIDs(resp)
+
+ // m1 should not be in fetch IDs since it was deleted
+ for _, id := range result.FetchIDs {
+ if id == "m1" {
+ t.Fatalf("deleted message m1 should not be in fetch ids: %v", result.FetchIDs)
+ }
+ }
+
+ // m1 should be in deleted IDs
+ found := false
+ for _, id := range result.DeletedIDs {
+ if id == "m1" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("deleted message m1 should be in deleted ids: %v", result.DeletedIDs)
+ }
+}
+
+func TestCollectHistoryMessageIDs_EmptyResponse(t *testing.T) {
+ result := collectHistoryMessageIDs(nil)
+ if len(result.FetchIDs) != 0 || len(result.DeletedIDs) != 0 {
+ t.Fatalf("expected empty result for nil response")
+ }
+
+ result = collectHistoryMessageIDs(&gmail.ListHistoryResponse{})
+ if len(result.FetchIDs) != 0 || len(result.DeletedIDs) != 0 {
+ t.Fatalf("expected empty result for empty response")
+ }
+}
+
+func TestParseHistoryTypes(t *testing.T) {
+ got, err := parseHistoryTypes([]string{"messageAdded,labelRemoved", "labelsAdded", "messagesDeleted", "messageAdded"})
+ if err != nil {
+ t.Fatalf("parseHistoryTypes: %v", err)
+ }
+ want := []string{"messageAdded", "labelRemoved", "labelAdded", "messageDeleted"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("unexpected types: %v", got)
+ }
+ if _, err := parseHistoryTypes([]string{"nope"}); err == nil {
+ t.Fatalf("expected error for invalid history type")
+ }
+}
+
+func TestParseHistoryTypes_DefaultsToMessageAdded(t *testing.T) {
+ // When no history types are provided, default to messageAdded for backward compatibility.
+ got, err := parseHistoryTypes(nil)
+ if err != nil {
+ t.Fatalf("parseHistoryTypes(nil): %v", err)
+ }
+ want := []string{"messageAdded"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("expected default %v, got %v", want, got)
+ }
+
+ // Empty slice should also default to messageAdded.
+ got, err = parseHistoryTypes([]string{})
+ if err != nil {
+ t.Fatalf("parseHistoryTypes([]string{}): %v", err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("expected default %v, got %v", want, got)
+ }
+}
+
+func TestParseHistoryTypes_EmptyStringsInInput(t *testing.T) {
+ // Test that empty strings between commas are handled correctly.
+ got, err := parseHistoryTypes([]string{"messageAdded,,labelAdded"})
+ if err != nil {
+ t.Fatalf("parseHistoryTypes with empty strings: %v", err)
+ }
+ want := []string{"messageAdded", "labelAdded"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("expected %v, got %v", want, got)
+ }
+
+ // Test leading/trailing empty parts.
+ got, err = parseHistoryTypes([]string{",messageAdded,", ",labelRemoved,"})
+ if err != nil {
+ t.Fatalf("parseHistoryTypes with leading/trailing empty: %v", err)
+ }
+ want = []string{"messageAdded", "labelRemoved"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("expected %v, got %v", want, got)
+ }
+
+ // Test whitespace-only parts.
+ got, err = parseHistoryTypes([]string{"messageAdded, ,labelAdded"})
+ if err != nil {
+ t.Fatalf("parseHistoryTypes with whitespace: %v", err)
+ }
+ want = []string{"messageAdded", "labelAdded"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("expected %v, got %v", want, got)
}
}
diff --git a/internal/cmd/gmail_watch_server_more_test.go b/internal/cmd/gmail_watch_server_more_test.go
index 4c445d3e..da233f19 100644
--- a/internal/cmd/gmail_watch_server_more_test.go
+++ b/internal/cmd/gmail_watch_server_more_test.go
@@ -141,6 +141,93 @@ func TestGmailWatchServer_ServeHTTP_AllowNoHook(t *testing.T) {
}
}
+func TestGmailWatchServer_ServeHTTP_HistoryTypes_NoMatch(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ store, err := newGmailWatchStore("a@b.com")
+ if err != nil {
+ t.Fatalf("store: %v", err)
+ }
+ // Seed state so StartHistoryID returns non-zero.
+ if updateErr := store.Update(func(s *gmailWatchState) error {
+ s.Account = "a@b.com"
+ s.HistoryID = "100"
+ return nil
+ }); updateErr != nil {
+ t.Fatalf("seed: %v", updateErr)
+ }
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "historyId": "200",
+ "history": []map[string]any{},
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ gsvc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ s := &gmailWatchServer{
+ cfg: gmailWatchServeConfig{
+ Account: "a@b.com",
+ Path: "/gmail-pubsub",
+ SharedToken: "tok",
+ AllowNoHook: true,
+ HistoryMax: 100,
+ ResyncMax: 10,
+ HistoryTypes: []string{"messageAdded"},
+ },
+ store: store,
+ newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
+ hookClient: srv.Client(),
+ logf: func(string, ...any) {},
+ warnf: func(string, ...any) {},
+ }
+
+ push := pubsubPushEnvelope{}
+ push.Message.Data = base64.StdEncoding.EncodeToString([]byte(`{"emailAddress":"a@b.com","historyId":"200"}`))
+ body, _ := json.Marshal(push)
+
+ req := httptest.NewRequest(http.MethodPost, "/gmail-pubsub?token=tok", bytes.NewReader(body))
+ req = req.WithContext(ctx)
+
+ rr := httptest.NewRecorder()
+ s.ServeHTTP(rr, req)
+ if rr.Code != http.StatusAccepted {
+ t.Fatalf("status: %d body=%q", rr.Code, rr.Body.String())
+ }
+ if rr.Body.Len() != 0 {
+ t.Fatalf("expected empty body, got %q", rr.Body.String())
+ }
+
+ st := store.Get()
+ if st.HistoryID != "200" {
+ t.Fatalf("expected history updated, got %q", st.HistoryID)
+ }
+}
+
func TestGmailWatchHelpers(t *testing.T) {
if got := bearerToken(&http.Request{Header: http.Header{"Authorization": []string{"Bearer tok"}}}); got != "tok" {
t.Fatalf("bearer: %q", got)
diff --git a/internal/cmd/gmail_watch_state.go b/internal/cmd/gmail_watch_state.go
index f67ca689..9b895936 100644
--- a/internal/cmd/gmail_watch_state.go
+++ b/internal/cmd/gmail_watch_state.go
@@ -33,7 +33,7 @@ func gmailWatchStatePath(account string) (string, error) {
func sanitizeAccountForPath(account string) string {
clean := strings.TrimSpace(strings.ToLower(account))
if clean == "" {
- return "unknown"
+ return trackingUnknown
}
var b strings.Builder
b.Grow(len(clean))
diff --git a/internal/cmd/gmail_watch_types.go b/internal/cmd/gmail_watch_types.go
index 67e7e613..4ca3abad 100644
--- a/internal/cmd/gmail_watch_types.go
+++ b/internal/cmd/gmail_watch_types.go
@@ -54,6 +54,7 @@ type gmailWatchServeConfig struct {
MaxBodyBytes int
HistoryMax int64
ResyncMax int64
+ HistoryTypes []string
HookTimeout time.Duration
DateLocation *time.Location
PersistHook bool
@@ -61,6 +62,51 @@ type gmailWatchServeConfig struct {
VerboseOutput bool
}
+var gmailHistoryTypeAliases = map[string]string{
+ // Canonical forms (for robustness when users provide exact casing)
+ "messageAdded": "messageAdded",
+ "messageDeleted": "messageDeleted",
+ "labelAdded": "labelAdded",
+ "labelRemoved": "labelRemoved",
+ // Lowercase aliases
+ "messageadded": "messageAdded",
+ "messagesadded": "messageAdded",
+ "messagedeleted": "messageDeleted",
+ "messagesdeleted": "messageDeleted",
+ "labeladded": "labelAdded",
+ "labelsadded": "labelAdded",
+ "labelremoved": "labelRemoved",
+ "labelsremoved": "labelRemoved",
+}
+
+func parseHistoryTypes(values []string) ([]string, error) {
+ if len(values) == 0 {
+ // Default to messageAdded for backward compatibility.
+ // Previously this was hardcoded; returning nil would fetch ALL types.
+ return []string{"messageAdded"}, nil
+ }
+ out := make([]string, 0, len(values))
+ seen := make(map[string]struct{})
+ for _, raw := range values {
+ for _, part := range strings.Split(raw, ",") {
+ trimmed := strings.TrimSpace(part)
+ if trimmed == "" {
+ continue
+ }
+ normalized, ok := gmailHistoryTypeAliases[strings.ToLower(trimmed)]
+ if !ok {
+ return nil, usage("--history-types must be one of messageAdded,messageDeleted,labelAdded,labelRemoved")
+ }
+ if _, exists := seen[normalized]; exists {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ out = append(out, normalized)
+ }
+ }
+ return out, nil
+}
+
type pubsubPushEnvelope struct {
Message struct {
Data string `json:"data"`
@@ -119,8 +165,9 @@ type gmailHookMessage struct {
}
type gmailHookPayload struct {
- Source string `json:"source"`
- Account string `json:"account"`
- HistoryID string `json:"historyId"`
- Messages []gmailHookMessage `json:"messages"`
+ Source string `json:"source"`
+ Account string `json:"account"`
+ HistoryID string `json:"historyId"`
+ Messages []gmailHookMessage `json:"messages"`
+ DeletedMessageIDs []string `json:"deletedMessageIds,omitempty"`
}
diff --git a/internal/cmd/groups.go b/internal/cmd/groups.go
index a398a057..1e99766e 100644
--- a/internal/cmd/groups.go
+++ b/internal/cmd/groups.go
@@ -24,8 +24,12 @@ const (
)
type GroupsCmd struct {
- List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"`
- Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"`
+ List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"`
+ Members GroupsMembersCmd `cmd:"" name:"members" help:"List or manage group members"`
+ Create GroupsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create a group (admin)"`
+ Update GroupsUpdateCmd `cmd:"" name:"update" help:"Update a group (admin)"`
+ Delete GroupsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a group (admin)"`
+ Settings GroupsSettingsCmd `cmd:"" name:"settings" help:"Get or update group settings (admin)"`
}
type GroupsListCmd struct {
@@ -132,9 +136,13 @@ func getRelationType(relationType string) string {
}
type GroupsMembersCmd struct {
- GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@company.com)"`
- Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
- Page string `name:"page" help:"Page token"`
+ Action string `arg:"" name:"action" help:"Action: add/remove/sync (default: list)" optional:""`
+ GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@company.com)" optional:""`
+ MemberEmail string `arg:"" name:"memberEmail" help:"Member email (for add/remove)" optional:""`
+ Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
+ Page string `name:"page" help:"Page token"`
+ Role string `name:"role" default:"MEMBER" enum:"MEMBER,MANAGER,OWNER" help:"Member role (add only)"`
+ File string `name:"file" help:"CSV file with member emails (sync only)"`
}
func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -144,9 +152,57 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
+ action := strings.ToLower(strings.TrimSpace(c.Action))
groupEmail := strings.TrimSpace(c.GroupEmail)
- if groupEmail == "" {
- return usage("group email required")
+ memberEmail := strings.TrimSpace(c.MemberEmail)
+
+ switch action {
+ case "", "list", "add", "remove", "sync":
+ default:
+ if groupEmail != "" {
+ return usage("unknown action (expected add, remove, sync)")
+ }
+ groupEmail = c.Action
+ action = ""
+ }
+
+ switch action {
+ case "":
+ if groupEmail == "" {
+ return usage("group email required")
+ }
+ case "list":
+ if groupEmail == "" {
+ return usage("group email required")
+ }
+ case "add":
+ if groupEmail == "" || memberEmail == "" {
+ return usage("group and member email required")
+ }
+ return (&GroupsMembersAddCmd{
+ Group: groupEmail,
+ Email: memberEmail,
+ Role: c.Role,
+ }).Run(ctx, flags)
+ case "remove":
+ if groupEmail == "" || memberEmail == "" {
+ return usage("group and member email required")
+ }
+ return (&GroupsMembersRemoveCmd{
+ Group: groupEmail,
+ Email: memberEmail,
+ }).Run(ctx, flags)
+ case "sync":
+ if groupEmail == "" {
+ return usage("group email required")
+ }
+ if strings.TrimSpace(c.File) == "" {
+ return usage("--file is required")
+ }
+ return (&GroupsMembersSyncCmd{
+ Group: groupEmail,
+ File: c.File,
+ }).Run(ctx, flags)
}
svc, err := newCloudIdentityService(ctx, account)
diff --git a/internal/cmd/groups_admin.go b/internal/cmd/groups_admin.go
new file mode 100644
index 00000000..13cb8f24
--- /dev/null
+++ b/internal/cmd/groups_admin.go
@@ -0,0 +1,435 @@
+package cmd
+
+import (
+ "context"
+ "encoding/csv"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+ "google.golang.org/api/groupssettings/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newGroupsSettings = googleapi.NewGroupsSettings
+
+// Admin group management
+
+type GroupsCreateCmd struct {
+ Email string `arg:"" name:"email" help:"Group email address"`
+ Name string `name:"name" required:"" help:"Display name"`
+ Description string `name:"description" help:"Description"`
+}
+
+func (c *GroupsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ req := &admin.Group{
+ Email: c.Email,
+ Name: c.Name,
+ Description: c.Description,
+ }
+ created, err := svc.Groups.Insert(req).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create group %s: %w", c.Email, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created group: %s\n", created.Email)
+ return nil
+}
+
+type GroupsUpdateCmd struct {
+ Group string `arg:"" name:"group" help:"Group email or ID"`
+ Name *string `name:"name" help:"New display name"`
+ Description *string `name:"description" help:"Description"`
+}
+
+func (c *GroupsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ group := &admin.Group{}
+ hasUpdates := false
+ if c.Name != nil {
+ group.Name = *c.Name
+ hasUpdates = true
+ }
+ if c.Description != nil {
+ group.Description = *c.Description
+ if *c.Description == "" {
+ group.ForceSendFields = append(group.ForceSendFields, "Description")
+ }
+ hasUpdates = true
+ }
+ if !hasUpdates {
+ return usage("no updates specified")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ updated, err := svc.Groups.Update(c.Group, group).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update group %s: %w", c.Group, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated group: %s\n", updated.Email)
+ return nil
+}
+
+type GroupsDeleteCmd struct {
+ Group string `arg:"" name:"group" help:"Group email or ID"`
+}
+
+func (c *GroupsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", c.Group)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Groups.Delete(c.Group).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete group %s: %w", c.Group, err)
+ }
+
+ u.Out().Printf("Deleted group: %s\n", c.Group)
+ return nil
+}
+
+type GroupsSettingsCmd struct {
+ Group string `arg:"" name:"group" help:"Group email"`
+ WhoCanJoin *string `name:"who-can-join" help:"Who can join (e.g., ANYONE_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN)"`
+ WhoCanPost *string `name:"who-can-post" help:"Who can post (e.g., ANYONE_CAN_POST, ALL_IN_DOMAIN_CAN_POST, OWNERS_ONLY, NONE_CAN_POST)"`
+ WhoCanViewGroup *string `name:"who-can-view-group" help:"Who can view group (e.g., ANYONE_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW)"`
+ WhoCanViewMembers *string `name:"who-can-view-membership" help:"Who can view membership (e.g., ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, OWNERS_ONLY)"`
+}
+
+func (c *GroupsSettingsCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newGroupsSettings(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ hasUpdates := c.WhoCanJoin != nil || c.WhoCanPost != nil || c.WhoCanViewGroup != nil || c.WhoCanViewMembers != nil
+ if !hasUpdates {
+ settings, getErr := svc.Groups.Get(c.Group).Context(ctx).Do()
+ if getErr != nil {
+ return fmt.Errorf("get group settings %s: %w", c.Group, getErr)
+ }
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, settings)
+ }
+ u.Out().Printf("Group: %s\n", settings.Email)
+ u.Out().Printf("WhoCanJoin: %s\n", settings.WhoCanJoin)
+ u.Out().Printf("WhoCanPostMessage: %s\n", settings.WhoCanPostMessage)
+ u.Out().Printf("WhoCanViewGroup: %s\n", settings.WhoCanViewGroup)
+ u.Out().Printf("WhoCanViewMembers: %s\n", settings.WhoCanViewMembership)
+ return nil
+ }
+
+ req := &groupssettings.Groups{}
+ if c.WhoCanJoin != nil {
+ req.WhoCanJoin = *c.WhoCanJoin
+ req.ForceSendFields = append(req.ForceSendFields, "WhoCanJoin")
+ }
+ if c.WhoCanPost != nil {
+ req.WhoCanPostMessage = *c.WhoCanPost
+ req.ForceSendFields = append(req.ForceSendFields, "WhoCanPostMessage")
+ }
+ if c.WhoCanViewGroup != nil {
+ req.WhoCanViewGroup = *c.WhoCanViewGroup
+ req.ForceSendFields = append(req.ForceSendFields, "WhoCanViewGroup")
+ }
+ if c.WhoCanViewMembers != nil {
+ req.WhoCanViewMembership = *c.WhoCanViewMembers
+ req.ForceSendFields = append(req.ForceSendFields, "WhoCanViewMembership")
+ }
+
+ updated, err := svc.Groups.Update(c.Group, req).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update group settings %s: %w", c.Group, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated settings for group: %s\n", updated.Email)
+ return nil
+}
+
+type GroupsMembersAddCmd struct {
+ Group string `arg:"" name:"group" help:"Group email or ID"`
+ Email string `arg:"" name:"email" help:"Member email"`
+ Role string `name:"role" default:"MEMBER" enum:"MEMBER,MANAGER,OWNER" help:"Member role"`
+}
+
+func (c *GroupsMembersAddCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ role, err := normalizeGroupRole(c.Role)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ member := &admin.Member{Email: c.Email, Role: role}
+ created, err := svc.Members.Insert(c.Group, member).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("add member %s to %s: %w", c.Email, c.Group, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Added %s to %s as %s\n", created.Email, c.Group, created.Role)
+ return nil
+}
+
+type GroupsMembersRemoveCmd struct {
+ Group string `arg:"" name:"group" help:"Group email or ID"`
+ Email string `arg:"" name:"email" help:"Member email"`
+}
+
+func (c *GroupsMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", c.Email, c.Group)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Members.Delete(c.Group, c.Email).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("remove member %s from %s: %w", c.Email, c.Group, err)
+ }
+
+ u.Out().Printf("Removed %s from %s\n", c.Email, c.Group)
+ return nil
+}
+
+type GroupsMembersSyncCmd struct {
+ Group string `arg:"" name:"group" help:"Group email or ID"`
+ File string `name:"file" required:"" help:"CSV file with member emails"`
+}
+
+func (c *GroupsMembersSyncCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ desiredEmails, err := readCSVEmails(c.File)
+ if err != nil {
+ return err
+ }
+ if len(desiredEmails) == 0 {
+ return usage("no member emails found in CSV")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ currentMembers, err := listGroupMembers(ctx, svc, c.Group)
+ if err != nil {
+ return err
+ }
+
+ desired := make(map[string]struct{}, len(desiredEmails))
+ for _, email := range desiredEmails {
+ desired[strings.ToLower(email)] = struct{}{}
+ }
+
+ toAdd := make([]string, 0)
+ for email := range desired {
+ if _, ok := currentMembers[email]; !ok {
+ toAdd = append(toAdd, email)
+ }
+ }
+ toRemove := make([]string, 0)
+ for email := range currentMembers {
+ if _, ok := desired[email]; !ok {
+ toRemove = append(toRemove, email)
+ }
+ }
+
+ sort.Strings(toAdd)
+ sort.Strings(toRemove)
+
+ if len(toAdd) == 0 && len(toRemove) == 0 {
+ u.Out().Printf("Group %s already in sync (%d members)\n", c.Group, len(currentMembers))
+ return nil
+ }
+
+ if len(toRemove) > 0 {
+ if err := confirmDestructive(ctx, flags, fmt.Sprintf("sync members for %s (add %d, remove %d)", c.Group, len(toAdd), len(toRemove))); err != nil {
+ return err
+ }
+ }
+
+ for _, email := range toAdd {
+ member := &admin.Member{Email: email, Role: "MEMBER"}
+ if _, err := svc.Members.Insert(c.Group, member).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("add member %s: %w", email, err)
+ }
+ }
+ for _, email := range toRemove {
+ if err := svc.Members.Delete(c.Group, email).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("remove member %s: %w", email, err)
+ }
+ }
+
+ u.Out().Printf("Synced group %s: added %d, removed %d\n", c.Group, len(toAdd), len(toRemove))
+ return nil
+}
+
+func normalizeGroupRole(role string) (string, error) {
+ role = strings.TrimSpace(strings.ToUpper(role))
+ switch role {
+ case "MEMBER", "MANAGER", "OWNER":
+ return role, nil
+ default:
+ return "", usage("invalid role (expected MEMBER, MANAGER, OWNER)")
+ }
+}
+
+func readCSVEmails(path string) ([]string, error) {
+ f, err := os.Open(path) //nolint:gosec // G304: user-provided file path is intentional
+ if err != nil {
+ return nil, fmt.Errorf("open CSV: %w", err)
+ }
+ defer f.Close()
+
+ reader := csv.NewReader(f)
+ reader.FieldsPerRecord = -1
+
+ records, err := reader.ReadAll()
+ if err != nil {
+ return nil, fmt.Errorf("read CSV: %w", err)
+ }
+ if len(records) == 0 {
+ return nil, nil
+ }
+
+ header := records[0]
+ idx := findEmailColumn(header)
+ start := 0
+ if idx >= 0 {
+ start = 1
+ } else {
+ idx = 0
+ }
+
+ seen := make(map[string]struct{})
+ out := make([]string, 0, len(records)-start)
+ for _, row := range records[start:] {
+ if idx >= len(row) {
+ continue
+ }
+ email := strings.TrimSpace(row[idx])
+ if email == "" {
+ continue
+ }
+ key := strings.ToLower(email)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, email)
+ }
+ return out, nil
+}
+
+func findEmailColumn(header []string) int {
+ for i, col := range header {
+ name := strings.TrimSpace(strings.ToLower(col))
+ switch name {
+ case "email", "emailaddress", "member", "member_email":
+ return i
+ }
+ }
+ return -1
+}
+
+func listGroupMembers(ctx context.Context, svc *admin.Service, group string) (map[string]struct{}, error) {
+ members := make(map[string]struct{})
+ call := svc.Members.List(group).MaxResults(200)
+ for {
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("list members for %s: %w", group, err)
+ }
+ for _, member := range resp.Members {
+ if member == nil || member.Email == "" {
+ continue
+ }
+ members[strings.ToLower(member.Email)] = struct{}{}
+ }
+ if resp.NextPageToken == "" {
+ break
+ }
+ call = call.PageToken(resp.NextPageToken)
+ }
+ return members, nil
+}
diff --git a/internal/cmd/labels.go b/internal/cmd/labels.go
new file mode 100644
index 00000000..ddf93e44
--- /dev/null
+++ b/internal/cmd/labels.go
@@ -0,0 +1,358 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/drivelabels/v2"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newDriveLabelsService = googleapi.NewDriveLabels
+
+type LabelsCmd struct {
+ List LabelsListCmd `cmd:"" name:"list" aliases:"ls" help:"List Drive labels"`
+ Get LabelsGetCmd `cmd:"" name:"get" help:"Get a Drive label"`
+ Create LabelsCreateCmd `cmd:"" name:"create" help:"Create a Drive label"`
+ Update LabelsUpdateCmd `cmd:"" name:"update" help:"Update a Drive label"`
+ Delete LabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a Drive label"`
+ Publish LabelsPublishCmd `cmd:"" name:"publish" help:"Publish a Drive label"`
+ Disable LabelsDisableCmd `cmd:"" name:"disable" help:"Disable a Drive label"`
+}
+
+type LabelsListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *LabelsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Labels.List()
+ if c.Max > 0 {
+ call = call.PageSize(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list labels: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Labels) == 0 {
+ u.Err().Println("No labels found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "NAME\tTITLE\tTYPE\tSTATE")
+ for _, label := range resp.Labels {
+ if label == nil {
+ continue
+ }
+ title := ""
+ if label.Properties != nil {
+ title = label.Properties.Title
+ }
+ state := ""
+ if label.Lifecycle != nil {
+ state = label.Lifecycle.State
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(label.Name),
+ sanitizeTab(title),
+ sanitizeTab(label.LabelType),
+ sanitizeTab(state),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type LabelsGetCmd struct {
+ LabelID string `arg:"" name:"label-id" help:"Label ID or name"`
+}
+
+func (c *LabelsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ label := strings.TrimSpace(c.LabelID)
+ if label == "" {
+ return usage("label-id is required")
+ }
+ label = normalizeLabelName(label)
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Labels.Get(label).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get label %s: %w", label, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ title := ""
+ if resp.Properties != nil {
+ title = resp.Properties.Title
+ }
+ u.Out().Printf("Name: %s\n", resp.Name)
+ u.Out().Printf("Title: %s\n", title)
+ u.Out().Printf("Type: %s\n", resp.LabelType)
+ if resp.Lifecycle != nil {
+ u.Out().Printf("State: %s\n", resp.Lifecycle.State)
+ }
+ return nil
+}
+
+type LabelsCreateCmd struct {
+ Name string `name:"name" help:"Label title" required:""`
+ Type string `name:"type" help:"Label type: ADMIN|SHARED" default:"ADMIN"`
+}
+
+func (c *LabelsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("--name is required")
+ }
+
+ labelType := strings.ToUpper(strings.TrimSpace(c.Type))
+ switch labelType {
+ case "ADMIN", "SHARED":
+ default:
+ return usage("invalid --type (expected ADMIN|SHARED)")
+ }
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ label := &drivelabels.GoogleAppsDriveLabelsV2Label{
+ LabelType: labelType,
+ Properties: &drivelabels.GoogleAppsDriveLabelsV2LabelProperties{
+ Title: name,
+ },
+ }
+ created, err := svc.Labels.Create(label).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create label: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created label: %s\n", created.Name)
+ return nil
+}
+
+type LabelsUpdateCmd struct {
+ LabelID string `arg:"" name:"label-id" help:"Label ID or name"`
+ Name *string `name:"name" help:"New label title"`
+}
+
+func (c *LabelsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ label := strings.TrimSpace(c.LabelID)
+ if label == "" {
+ return usage("label-id is required")
+ }
+ label = normalizeLabelName(label)
+
+ if c.Name == nil {
+ return usage("no updates specified")
+ }
+ newTitle := strings.TrimSpace(*c.Name)
+ if newTitle == "" {
+ return usage("--name cannot be empty")
+ }
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ req := &drivelabels.GoogleAppsDriveLabelsV2DeltaUpdateLabelRequest{
+ Requests: []*drivelabels.GoogleAppsDriveLabelsV2DeltaUpdateLabelRequestRequest{
+ {
+ UpdateLabel: &drivelabels.GoogleAppsDriveLabelsV2DeltaUpdateLabelRequestUpdateLabelPropertiesRequest{
+ Properties: &drivelabels.GoogleAppsDriveLabelsV2LabelProperties{Title: newTitle},
+ UpdateMask: "title",
+ },
+ },
+ },
+ }
+
+ updated, err := svc.Labels.Delta(label, req).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update label: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated label: %s\n", label)
+ return nil
+}
+
+type LabelsDeleteCmd struct {
+ LabelID string `arg:"" name:"label-id" help:"Label ID or name"`
+}
+
+func (c *LabelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ label := strings.TrimSpace(c.LabelID)
+ if label == "" {
+ return usage("label-id is required")
+ }
+ label = normalizeLabelName(label)
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete label %s", label)); err != nil {
+ return err
+ }
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if _, err := svc.Labels.Delete(label).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete label: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"label": label, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted label: %s\n", label)
+ return nil
+}
+
+type LabelsPublishCmd struct {
+ LabelID string `arg:"" name:"label-id" help:"Label ID or name"`
+}
+
+func (c *LabelsPublishCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ label := strings.TrimSpace(c.LabelID)
+ if label == "" {
+ return usage("label-id is required")
+ }
+ label = normalizeLabelName(label)
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Labels.Publish(label, &drivelabels.GoogleAppsDriveLabelsV2PublishLabelRequest{}).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("publish label: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Published label: %s\n", label)
+ return nil
+}
+
+type LabelsDisableCmd struct {
+ LabelID string `arg:"" name:"label-id" help:"Label ID or name"`
+}
+
+func (c *LabelsDisableCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ label := strings.TrimSpace(c.LabelID)
+ if label == "" {
+ return usage("label-id is required")
+ }
+ label = normalizeLabelName(label)
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("disable label %s", label)); err != nil {
+ return err
+ }
+
+ svc, err := newDriveLabelsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Labels.Disable(label, &drivelabels.GoogleAppsDriveLabelsV2DisableLabelRequest{}).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("disable label: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Disabled label: %s\n", label)
+ return nil
+}
+
+func normalizeLabelName(input string) string {
+ trimmed := strings.TrimSpace(input)
+ if strings.HasPrefix(trimmed, "labels/") {
+ return trimmed
+ }
+ return "labels/" + trimmed
+}
diff --git a/internal/cmd/labels_test.go b/internal/cmd/labels_test.go
new file mode 100644
index 00000000..aab5bddc
--- /dev/null
+++ b/internal/cmd/labels_test.go
@@ -0,0 +1,800 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/drivelabels/v2"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+// ========== LabelsListCmd Tests ==========
+
+func TestLabelsListCmd_Text(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "labels": []map[string]any{
+ {"name": "labels/label1", "labelType": "ADMIN", "properties": map[string]any{"title": "Confidential"}, "lifecycle": map[string]any{"state": "PUBLISHED"}},
+ {"name": "labels/label2", "labelType": "SHARED", "properties": map[string]any{"title": "Public"}, "lifecycle": map[string]any{"state": "DRAFT"}},
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Confidential") {
+ t.Fatalf("expected output to contain 'Confidential', got: %s", out)
+ }
+ if !strings.Contains(out, "Public") {
+ t.Fatalf("expected output to contain 'Public', got: %s", out)
+ }
+ if !strings.Contains(out, "ADMIN") {
+ t.Fatalf("expected output to contain 'ADMIN', got: %s", out)
+ }
+ if !strings.Contains(out, "PUBLISHED") {
+ t.Fatalf("expected output to contain 'PUBLISHED', got: %s", out)
+ }
+}
+
+func TestLabelsListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "labels": []map[string]any{
+ {"name": "labels/label1", "labelType": "ADMIN", "properties": map[string]any{"title": "Confidential"}},
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsListCmd{}
+
+ ctx := outfmt.WithMode(testContext(t), outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON output: %v, output: %s", err, out)
+ }
+
+ labels, ok := result["labels"].([]any)
+ if !ok {
+ t.Fatalf("expected labels array in JSON output, got: %v", result)
+ }
+ if len(labels) != 1 {
+ t.Fatalf("expected 1 label, got %d", len(labels))
+ }
+}
+
+func TestLabelsListCmd_EmptyResults(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "labels": []map[string]any{},
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsListCmd{}
+
+ stderr := captureStderr(t, func() {
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStderr(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+ })
+
+ if !strings.Contains(stderr, "No labels") {
+ t.Fatalf("expected 'No labels' message in stderr, got: %s", stderr)
+ }
+}
+
+func TestLabelsListCmd_Pagination(t *testing.T) {
+ var gotPageSize int64
+ var gotPageToken string
+
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Extract query params
+ if ps := r.URL.Query().Get("pageSize"); ps != "" {
+ var v int64
+ if err := json.Unmarshal([]byte(ps), &v); err == nil {
+ gotPageSize = v
+ }
+ }
+ gotPageToken = r.URL.Query().Get("pageToken")
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "labels": []map[string]any{
+ {"name": "labels/label1", "labelType": "ADMIN", "properties": map[string]any{"title": "Test"}},
+ },
+ "nextPageToken": "next-token-123",
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsListCmd{Max: 10, Page: "prev-token"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPageSize != 10 {
+ t.Fatalf("expected pageSize 10, got %d", gotPageSize)
+ }
+ if gotPageToken != "prev-token" {
+ t.Fatalf("expected pageToken 'prev-token', got %q", gotPageToken)
+ }
+}
+
+func TestLabelsListCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 500,
+ "message": "Internal server error",
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "list labels") {
+ t.Fatalf("expected error to contain 'list labels', got: %v", err)
+ }
+}
+
+// ========== LabelsGetCmd Tests ==========
+
+func TestLabelsGetCmd_Text(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels/label1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "labels/label1",
+ "labelType": "ADMIN",
+ "properties": map[string]any{"title": "Confidential"},
+ "lifecycle": map[string]any{"state": "PUBLISHED"},
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsGetCmd{LabelID: "label1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "labels/label1") {
+ t.Fatalf("expected output to contain 'labels/label1', got: %s", out)
+ }
+ if !strings.Contains(out, "Confidential") {
+ t.Fatalf("expected output to contain 'Confidential', got: %s", out)
+ }
+ if !strings.Contains(out, "ADMIN") {
+ t.Fatalf("expected output to contain 'ADMIN', got: %s", out)
+ }
+ if !strings.Contains(out, "PUBLISHED") {
+ t.Fatalf("expected output to contain 'PUBLISHED', got: %s", out)
+ }
+}
+
+func TestLabelsGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels/label1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "labels/label1",
+ "labelType": "ADMIN",
+ "properties": map[string]any{"title": "Confidential"},
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsGetCmd{LabelID: "label1"}
+
+ ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON output: %v, output: %s", err, out)
+ }
+
+ if result["name"] != "labels/label1" {
+ t.Fatalf("expected name 'labels/label1', got: %v", result["name"])
+ }
+}
+
+func TestLabelsGetCmd_NormalizesLabelName(t *testing.T) {
+ var gotPath string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "labels/my-label",
+ "labelType": "ADMIN",
+ "properties": map[string]any{"title": "Test"},
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ // Pass label without "labels/" prefix - should be normalized
+ cmd := &LabelsGetCmd{LabelID: "my-label"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // The path should contain "labels/my-label"
+ if !strings.Contains(gotPath, "labels/my-label") {
+ t.Fatalf("expected path to contain 'labels/my-label', got: %s", gotPath)
+ }
+}
+
+func TestLabelsGetCmd_EmptyLabelID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsGetCmd{LabelID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty label-id, got nil")
+ }
+ if !strings.Contains(err.Error(), "label-id is required") {
+ t.Fatalf("expected error to contain 'label-id is required', got: %v", err)
+ }
+}
+
+func TestLabelsGetCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 404,
+ "message": "Label not found",
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsGetCmd{LabelID: "nonexistent"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "get label") {
+ t.Fatalf("expected error to contain 'get label', got: %v", err)
+ }
+}
+
+// ========== LabelsCreateCmd Tests ==========
+
+func TestLabelsCreateCmd_Text(t *testing.T) {
+ var gotTitle string
+ var gotType string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ LabelType string `json:"labelType"`
+ Properties struct {
+ Title string `json:"title"`
+ } `json:"properties"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotType = payload.LabelType
+ gotTitle = payload.Properties.Title
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "labels/label1", "labelType": payload.LabelType, "properties": map[string]any{"title": payload.Properties.Title}})
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsCreateCmd{Name: "Confidential", Type: "ADMIN"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotTitle != "Confidential" || gotType != "ADMIN" {
+ t.Fatalf("unexpected payload: title=%q type=%q", gotTitle, gotType)
+ }
+ if !strings.Contains(out, "Created label") {
+ t.Fatalf("expected output to contain 'Created label', got: %s", out)
+ }
+}
+
+func TestLabelsCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "labels/new-label",
+ "labelType": "SHARED",
+ "properties": map[string]any{"title": "My New Label"},
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsCreateCmd{Name: "My New Label", Type: "SHARED"}
+
+ ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON output: %v, output: %s", err, out)
+ }
+
+ if result["name"] != "labels/new-label" {
+ t.Fatalf("expected name 'labels/new-label', got: %v", result["name"])
+ }
+}
+
+func TestLabelsCreateCmd_EmptyName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsCreateCmd{Name: " ", Type: "ADMIN"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty name, got nil")
+ }
+ if !strings.Contains(err.Error(), "--name is required") {
+ t.Fatalf("expected error to contain '--name is required', got: %v", err)
+ }
+}
+
+func TestLabelsCreateCmd_InvalidType(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsCreateCmd{Name: "Test Label", Type: "INVALID"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for invalid type, got nil")
+ }
+ if !strings.Contains(err.Error(), "invalid --type") {
+ t.Fatalf("expected error to contain 'invalid --type', got: %v", err)
+ }
+}
+
+func TestLabelsCreateCmd_TypeCaseInsensitive(t *testing.T) {
+ var gotType string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ LabelType string `json:"labelType"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotType = payload.LabelType
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "labels/label1"})
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ // Test lowercase input
+ cmd := &LabelsCreateCmd{Name: "Test", Type: "shared"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotType != "SHARED" {
+ t.Fatalf("expected type to be normalized to 'SHARED', got: %s", gotType)
+ }
+}
+
+func TestLabelsCreateCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 403,
+ "message": "Insufficient permissions",
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsCreateCmd{Name: "Test", Type: "ADMIN"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "create label") {
+ t.Fatalf("expected error to contain 'create label', got: %v", err)
+ }
+}
+
+// ========== LabelsDeleteCmd Tests ==========
+
+func TestLabelsDeleteCmd_Text(t *testing.T) {
+ var deleteCalled bool
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/v2/labels/label1") {
+ http.NotFound(w, r)
+ return
+ }
+ deleteCalled = true
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LabelsDeleteCmd{LabelID: "label1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !deleteCalled {
+ t.Fatal("expected delete API to be called")
+ }
+ if !strings.Contains(out, "Deleted label") {
+ t.Fatalf("expected output to contain 'Deleted label', got: %s", out)
+ }
+}
+
+func TestLabelsDeleteCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/v2/labels/label1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LabelsDeleteCmd{LabelID: "label1"}
+
+ ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON output: %v, output: %s", err, out)
+ }
+
+ if result["deleted"] != true {
+ t.Fatalf("expected deleted=true, got: %v", result["deleted"])
+ }
+ if result["label"] != "labels/label1" {
+ t.Fatalf("expected label='labels/label1', got: %v", result["label"])
+ }
+}
+
+func TestLabelsDeleteCmd_EmptyLabelID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LabelsDeleteCmd{LabelID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty label-id, got nil")
+ }
+ if !strings.Contains(err.Error(), "label-id is required") {
+ t.Fatalf("expected error to contain 'label-id is required', got: %v", err)
+ }
+}
+
+func TestLabelsDeleteCmd_NormalizesLabelName(t *testing.T) {
+ var gotPath string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ // Pass label without "labels/" prefix - should be normalized
+ cmd := &LabelsDeleteCmd{LabelID: "my-label-to-delete"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(gotPath, "labels/my-label-to-delete") {
+ t.Fatalf("expected path to contain 'labels/my-label-to-delete', got: %s", gotPath)
+ }
+}
+
+func TestLabelsDeleteCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 404,
+ "message": "Label not found",
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LabelsDeleteCmd{LabelID: "nonexistent"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "delete label") {
+ t.Fatalf("expected error to contain 'delete label', got: %v", err)
+ }
+}
+
+// ========== LabelsUpdateCmd Tests ==========
+
+func TestLabelsUpdateCmd_Text(t *testing.T) {
+ var gotTitle string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels/label1:delta") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ Requests []struct {
+ UpdateLabel struct {
+ Properties struct {
+ Title string `json:"title"`
+ } `json:"properties"`
+ UpdateMask string `json:"updateMask"`
+ } `json:"updateLabel"`
+ } `json:"requests"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ if len(payload.Requests) > 0 {
+ gotTitle = payload.Requests[0].UpdateLabel.Properties.Title
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"updated": true})
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ name := "Renamed"
+ cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &name}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotTitle != "Renamed" {
+ t.Fatalf("unexpected title: %q", gotTitle)
+ }
+ if !strings.Contains(out, "Updated label") {
+ t.Fatalf("expected output to contain 'Updated label', got: %s", out)
+ }
+}
+
+func TestLabelsUpdateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels/label1:delta") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "responses": []map[string]any{{"updateLabel": map[string]any{}}},
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ name := "New Title"
+ cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &name}
+
+ ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON output: %v, output: %s", err, out)
+ }
+
+ if _, ok := result["responses"]; !ok {
+ t.Fatalf("expected 'responses' in JSON output, got: %v", result)
+ }
+}
+
+func TestLabelsUpdateCmd_EmptyLabelID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ name := "New Title"
+ cmd := &LabelsUpdateCmd{LabelID: " ", Name: &name}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty label-id, got nil")
+ }
+ if !strings.Contains(err.Error(), "label-id is required") {
+ t.Fatalf("expected error to contain 'label-id is required', got: %v", err)
+ }
+}
+
+func TestLabelsUpdateCmd_NoUpdates(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LabelsUpdateCmd{LabelID: "label1", Name: nil}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no updates, got nil")
+ }
+ if !strings.Contains(err.Error(), "no updates specified") {
+ t.Fatalf("expected error to contain 'no updates specified', got: %v", err)
+ }
+}
+
+func TestLabelsUpdateCmd_EmptyNameValue(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ emptyName := " "
+ cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &emptyName}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty name value, got nil")
+ }
+ if !strings.Contains(err.Error(), "--name cannot be empty") {
+ t.Fatalf("expected error to contain '--name cannot be empty', got: %v", err)
+ }
+}
+
+func TestLabelsUpdateCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 500,
+ "message": "Update failed",
+ },
+ })
+ })
+ stubDriveLabels(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ name := "New Title"
+ cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &name}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "update label") {
+ t.Fatalf("expected error to contain 'update label', got: %v", err)
+ }
+}
+
+// ========== Helper Functions ==========
+
+func stubDriveLabels(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newDriveLabelsService
+ svc, err := drivelabels.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new drive labels service: %v", err)
+ }
+ newDriveLabelsService = func(context.Context, string) (*drivelabels.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newDriveLabelsService = orig
+ srv.Close()
+ })
+}
diff --git a/internal/cmd/licenses.go b/internal/cmd/licenses.go
new file mode 100644
index 00000000..a33058f2
--- /dev/null
+++ b/internal/cmd/licenses.go
@@ -0,0 +1,370 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/licensing/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newLicensingService = googleapi.NewLicensing
+
+type LicensesCmd struct {
+ List LicensesListCmd `cmd:"" name:"list" aliases:"ls" help:"List license assignments"`
+ Get LicensesGetCmd `cmd:"" name:"get" help:"Get a license assignment"`
+ Assign LicensesAssignCmd `cmd:"" name:"assign" help:"Assign a license"`
+ Revoke LicensesRevokeCmd `cmd:"" name:"revoke" help:"Revoke a license"`
+ Products LicensesProductsCmd `cmd:"" name:"products" help:"List available products and SKUs"`
+}
+
+type LicensesListCmd struct {
+ Product string `name:"product" help:"Product ID"`
+ SKU string `name:"sku" help:"SKU ID"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *LicensesListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ product := strings.TrimSpace(c.Product)
+ if product == "" {
+ return usage("--product is required")
+ }
+
+ svc, err := newLicensingService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ customer := adminCustomerID()
+ var resp *licensing.LicenseAssignmentList
+ if strings.TrimSpace(c.SKU) != "" {
+ call := svc.LicenseAssignments.ListForProductAndSku(product, c.SKU, customer)
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ resp, err = call.Context(ctx).Do()
+ } else {
+ call := svc.LicenseAssignments.ListForProduct(product, customer)
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ resp, err = call.Context(ctx).Do()
+ }
+ if err != nil {
+ return fmt.Errorf("list licenses: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Items) == 0 {
+ u.Err().Println("No licenses found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "USER\tPRODUCT\tSKU")
+ for _, item := range resp.Items {
+ if item == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(item.UserId),
+ sanitizeTab(item.ProductId),
+ sanitizeTab(item.SkuId),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type LicensesGetCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ Product string `name:"product" help:"Product ID" required:""`
+ SKU string `name:"sku" help:"SKU ID" required:""`
+}
+
+func (c *LicensesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ user := strings.TrimSpace(c.User)
+ if user == "" {
+ return usage("user is required")
+ }
+
+ svc, err := newLicensingService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ assignment, err := svc.LicenseAssignments.Get(c.Product, c.SKU, user).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get license for %s: %w", user, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, assignment)
+ }
+
+ u.Out().Printf("User: %s\n", assignment.UserId)
+ u.Out().Printf("Product: %s\n", assignment.ProductId)
+ if assignment.ProductName != "" {
+ u.Out().Printf("Product Name: %s\n", assignment.ProductName)
+ }
+ u.Out().Printf("SKU: %s\n", assignment.SkuId)
+ if assignment.SkuName != "" {
+ u.Out().Printf("SKU Name: %s\n", assignment.SkuName)
+ }
+ return nil
+}
+
+type LicensesAssignCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ Product string `name:"product" help:"Product ID" required:""`
+ SKU string `name:"sku" help:"SKU ID" required:""`
+}
+
+func (c *LicensesAssignCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ user := strings.TrimSpace(c.User)
+ if user == "" {
+ return usage("user is required")
+ }
+
+ svc, err := newLicensingService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ insert := &licensing.LicenseAssignmentInsert{UserId: user}
+ assignment, err := svc.LicenseAssignments.Insert(c.Product, c.SKU, insert).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("assign license to %s: %w", user, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, assignment)
+ }
+
+ u.Out().Printf("Assigned license %s/%s to %s\n", assignment.ProductId, assignment.SkuId, assignment.UserId)
+ return nil
+}
+
+type LicensesRevokeCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ Product string `name:"product" help:"Product ID" required:""`
+ SKU string `name:"sku" help:"SKU ID" required:""`
+}
+
+func (c *LicensesRevokeCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ user := strings.TrimSpace(c.User)
+ if user == "" {
+ return usage("user is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("revoke license %s/%s for %s", c.Product, c.SKU, user)); err != nil {
+ return err
+ }
+
+ svc, err := newLicensingService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if _, err := svc.LicenseAssignments.Delete(c.Product, c.SKU, user).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("revoke license for %s: %w", user, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"user": user, "product": c.Product, "sku": c.SKU, "revoked": true})
+ }
+
+ u.Out().Printf("Revoked license %s/%s for %s\n", c.Product, c.SKU, user)
+ return nil
+}
+
+type LicensesProductsCmd struct{}
+
+func (c *LicensesProductsCmd) Run(ctx context.Context, _ *RootFlags) error {
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, licenseProducts)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "PRODUCT ID\tPRODUCT NAME\tSKU ID\tSKU NAME")
+ for _, product := range licenseProducts {
+ for _, sku := range product.SKUs {
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(product.ID),
+ sanitizeTab(product.Name),
+ sanitizeTab(sku.ID),
+ sanitizeTab(sku.Name),
+ )
+ }
+ }
+ return nil
+}
+
+type licenseSKU struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ UnarchivalProduct string `json:"unarchivalProduct,omitempty"`
+ UnarchivalSKU string `json:"unarchivalSku,omitempty"`
+ UnarchivalSKUName string `json:"unarchivalSkuName,omitempty"`
+ UnarchivalProdName string `json:"unarchivalProductName,omitempty"`
+}
+
+type licenseProduct struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ SKUs []licenseSKU `json:"skus"`
+}
+
+var licenseProducts = []licenseProduct{
+ {
+ ID: "Google-Apps",
+ Name: "Google Workspace",
+ SKUs: []licenseSKU{
+ {ID: "1010020027", Name: "Google Workspace Business Starter"},
+ {ID: "1010020028", Name: "Google Workspace Business Standard"},
+ {ID: "1010020025", Name: "Google Workspace Business Plus"},
+ {ID: "1010060003", Name: "Google Workspace Enterprise Essentials"},
+ {ID: "1010020029", Name: "Google Workspace Enterprise Starter"},
+ {ID: "1010020026", Name: "Google Workspace Enterprise Standard"},
+ {ID: "1010020020", Name: "Google Workspace Enterprise Plus (formerly G Suite Enterprise)"},
+ {ID: "1010060001", Name: "Google Workspace Essentials (formerly G Suite Essentials)"},
+ {ID: "1010060005", Name: "Google Workspace Enterprise Essentials Plus"},
+ {ID: "1010020030", Name: "Google Workspace Frontline Starter"},
+ {ID: "1010020031", Name: "Google Workspace Frontline Standard"},
+ {ID: "1010020034", Name: "Google Workspace Frontline Plus"},
+ {ID: "1010020035", Name: "Google Workspace Business Continuity"},
+ {ID: "1010020036", Name: "Google Workspace Business Continuity Plus"},
+ {ID: "Google-Apps-Unlimited", Name: "G Suite Business"},
+ {ID: "Google-Apps-For-Business", Name: "G Suite Basic"},
+ {ID: "Google-Apps-Lite", Name: "G Suite Lite"},
+ {ID: "Google-Apps-For-Postini", Name: "Google Apps Message Security"},
+ {ID: "Google-Apps-For-Education", Name: "Google Workspace for Education - Fundamentals"},
+ {ID: "1010070001", Name: "Google Workspace for Education Fundamentals"},
+ {ID: "1010070004", Name: "Google Workspace for Education Gmail Only"},
+ },
+ },
+ {
+ ID: "101047",
+ Name: "Google AI",
+ SKUs: []licenseSKU{
+ {ID: "1010470008", Name: "Google AI Ultra for Business"},
+ {ID: "1010470004", Name: "Google AI Pro for Education"},
+ {ID: "1010470005", Name: "Gemini Education Premium"},
+ },
+ },
+ {
+ ID: "101031",
+ Name: "Google Workspace for Education",
+ SKUs: []licenseSKU{
+ {ID: "1010310005", Name: "Google Workspace for Education Standard"},
+ {ID: "1010310006", Name: "Google Workspace for Education Standard (Staff)"},
+ {ID: "1010310007", Name: "Google Workspace for Education Standard (Extra Student)"},
+ {ID: "1010310008", Name: "Google Workspace for Education Plus"},
+ {ID: "1010310009", Name: "Google Workspace for Education Plus (Staff)"},
+ {ID: "1010310010", Name: "Google Workspace for Education Plus (Extra Student)"},
+ {ID: "1010310002", Name: "Google Workspace for Education Plus - Legacy"},
+ {ID: "1010310003", Name: "Google Workspace for Education Plus - Legacy (Student)"},
+ },
+ },
+ {
+ ID: "101037",
+ Name: "Google Workspace for Education: Teaching and Learning Upgrade",
+ SKUs: []licenseSKU{
+ {ID: "1010370001", Name: "Google Workspace for Education: Teaching and Learning Upgrade"},
+ },
+ },
+ {
+ ID: "101038",
+ Name: "AppSheet",
+ SKUs: []licenseSKU{
+ {ID: "1010380001", Name: "AppSheet Core"},
+ {ID: "1010380002", Name: "AppSheet Enterprise Standard"},
+ {ID: "1010380003", Name: "AppSheet Enterprise Plus"},
+ },
+ },
+ {
+ ID: "Google-Vault",
+ Name: "Google Vault",
+ SKUs: []licenseSKU{
+ {ID: "Google-Vault", Name: "Google Vault"},
+ {ID: "Google-Vault-Former-Employee", Name: "Google Vault - Former Employee"},
+ },
+ },
+ {
+ ID: "101001",
+ Name: "Cloud Identity",
+ SKUs: []licenseSKU{
+ {ID: "1010010001", Name: "Cloud Identity"},
+ },
+ },
+ {
+ ID: "101005",
+ Name: "Cloud Identity Premium",
+ SKUs: []licenseSKU{
+ {ID: "1010050001", Name: "Cloud Identity Premium"},
+ },
+ },
+ {
+ ID: "101033",
+ Name: "Google Voice",
+ SKUs: []licenseSKU{
+ {ID: "1010330003", Name: "Google Voice Starter"},
+ {ID: "1010330004", Name: "Google Voice Standard"},
+ {ID: "1010330002", Name: "Google Voice Premier"},
+ },
+ },
+ {
+ ID: "101034",
+ Name: "Google Workspace Archived User",
+ SKUs: []licenseSKU{
+ {ID: "1010340007", Name: "Google Workspace for Education Fundamentals - Archived User"},
+ {ID: "1010340004", Name: "Google Workspace Enterprise Standard - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020026"},
+ {ID: "1010340001", Name: "Google Workspace Enterprise Plus - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020020"},
+ {ID: "1010340005", Name: "Google Workspace Business Starter - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020027"},
+ {ID: "1010340006", Name: "Google Workspace Business Standard - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020028"},
+ {ID: "1010340003", Name: "Google Workspace Business Plus - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020025"},
+ {ID: "1010340002", Name: "G Suite Business - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "Google-Apps-Unlimited"},
+ },
+ },
+}
diff --git a/internal/cmd/licenses_test.go b/internal/cmd/licenses_test.go
new file mode 100644
index 00000000..9cdeab5f
--- /dev/null
+++ b/internal/cmd/licenses_test.go
@@ -0,0 +1,480 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/licensing/v1"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func TestLicensesListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/licensing/v1/product/Google-Apps/sku/1010020027/users") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"userId": "user@example.com", "productId": "Google-Apps", "skuId": "1010020027"},
+ },
+ })
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesListCmd{Product: "Google-Apps", SKU: "1010020027"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "user@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLicensesListCmd_ProductOnly(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Should call ListForProduct, not ListForProductAndSku
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/licensing/v1/product/Google-Apps/users") {
+ http.NotFound(w, r)
+ return
+ }
+ // Ensure it's not calling the SKU-specific endpoint
+ if strings.Contains(r.URL.Path, "/sku/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"userId": "user1@example.com", "productId": "Google-Apps", "skuId": "1010020027"},
+ {"userId": "user2@example.com", "productId": "Google-Apps", "skuId": "1010020028"},
+ },
+ })
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesListCmd{Product: "Google-Apps"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "user1@example.com") || !strings.Contains(out, "user2@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLicensesListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"userId": "user@example.com", "productId": "Google-Apps", "skuId": "1010020027"},
+ },
+ })
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesListCmd{Product: "Google-Apps", SKU: "1010020027"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"items"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestLicensesListCmd_MissingProduct(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing product")
+ }
+ if !strings.Contains(err.Error(), "--product is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestLicensesGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+ if !strings.Contains(r.URL.Path, "/user@example.com") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "userId": "user@example.com",
+ "productId": "Google-Apps",
+ "productName": "Google Workspace",
+ "skuId": "1010020027",
+ "skuName": "Google Workspace Business Starter",
+ })
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesGetCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "User:") || !strings.Contains(out, "user@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "Product:") || !strings.Contains(out, "Google-Apps") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "SKU:") || !strings.Contains(out, "1010020027") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLicensesGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "userId": "user@example.com",
+ "productId": "Google-Apps",
+ "skuId": "1010020027",
+ })
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesGetCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"userId"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestLicensesGetCmd_MissingUser(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesGetCmd{User: "", Product: "Google-Apps", SKU: "1010020027"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing user")
+ }
+ if !strings.Contains(err.Error(), "user is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestLicensesGetCmd_WithProductAndSkuName(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "userId": "user@example.com",
+ "productId": "Google-Apps",
+ "productName": "Google Workspace",
+ "skuId": "1010020027",
+ "skuName": "Business Starter",
+ })
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesGetCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Product Name:") || !strings.Contains(out, "Google Workspace") {
+ t.Fatalf("expected product name, got: %s", out)
+ }
+ if !strings.Contains(out, "SKU Name:") || !strings.Contains(out, "Business Starter") {
+ t.Fatalf("expected sku name, got: %s", out)
+ }
+}
+
+func TestLicensesAssignCmd(t *testing.T) {
+ var gotUser string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/apps/licensing/v1/product/Google-Apps/sku/1010020027/user"):
+ var payload struct {
+ UserId string `json:"userId"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotUser = payload.UserId
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "userId": payload.UserId,
+ "productId": "Google-Apps",
+ "skuId": "1010020027",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesAssignCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotUser != "user@example.com" {
+ t.Fatalf("unexpected user: %q", gotUser)
+ }
+ if !strings.Contains(out, "Assigned license") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLicensesAssignCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "userId": "user@example.com",
+ "productId": "Google-Apps",
+ "skuId": "1010020027",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesAssignCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"userId"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestLicensesAssignCmd_MissingUser(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &LicensesAssignCmd{User: " ", Product: "Google-Apps", SKU: "1010020027"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing user")
+ }
+ if !strings.Contains(err.Error(), "user is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestLicensesRevokeCmd(t *testing.T) {
+ var deleteCalled bool
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/user@example.com") {
+ deleteCalled = true
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LicensesRevokeCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !deleteCalled {
+ t.Fatal("delete was not called")
+ }
+ if !strings.Contains(out, "Revoked license") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLicensesRevokeCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubLicensing(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LicensesRevokeCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"revoked"`) || !strings.Contains(out, `true`) {
+ t.Fatalf("expected JSON output with revoked: true, got: %s", out)
+ }
+}
+
+func TestLicensesRevokeCmd_MissingUser(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &LicensesRevokeCmd{User: "", Product: "Google-Apps", SKU: "1010020027"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing user")
+ }
+ if !strings.Contains(err.Error(), "user is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestLicensesRevokeCmd_RequiresConfirmation(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: false, NoInput: true}
+ cmd := &LicensesRevokeCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error when confirmation is required but not provided")
+ }
+ if !strings.Contains(err.Error(), "refusing") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestLicensesProductsCmd(t *testing.T) {
+ cmd := &LicensesProductsCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), &RootFlags{}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Google-Apps") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLicensesProductsCmd_JSON(t *testing.T) {
+ cmd := &LicensesProductsCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, &RootFlags{}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"id"`) || !strings.Contains(out, `"skus"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestLicensesProductsCmd_ContainsExpectedProducts(t *testing.T) {
+ cmd := &LicensesProductsCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), &RootFlags{}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ expectedProducts := []string{
+ "Google-Apps",
+ "Google Workspace",
+ "Cloud Identity",
+ "Google Vault",
+ }
+ for _, p := range expectedProducts {
+ if !strings.Contains(out, p) {
+ t.Errorf("expected output to contain %q", p)
+ }
+ }
+}
+
+func stubLicensing(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newLicensingService
+ svc, err := licensing.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new licensing service: %v", err)
+ }
+ newLicensingService = func(context.Context, string) (*licensing.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newLicensingService = orig
+ srv.Close()
+ })
+}
diff --git a/internal/cmd/lookerstudio.go b/internal/cmd/lookerstudio.go
new file mode 100644
index 00000000..2b139fa6
--- /dev/null
+++ b/internal/cmd/lookerstudio.go
@@ -0,0 +1,184 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/drive/v3"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// LookerStudioCmd manages Looker Studio assets via Drive permissions.
+type LookerStudioCmd struct {
+ Permissions LookerStudioPermissionsCmd `cmd:"" name:"permissions" help:"Manage Looker Studio permissions"`
+}
+
+type LookerStudioPermissionsCmd struct {
+ List LookerStudioPermissionsListCmd `cmd:"" name:"list" help:"List permissions for a Looker Studio asset"`
+ Add LookerStudioPermissionsAddCmd `cmd:"" name:"add" help:"Add a permission to a Looker Studio asset"`
+ Remove LookerStudioPermissionsRemoveCmd `cmd:"" name:"remove" help:"Remove a permission from a Looker Studio asset"`
+}
+
+type LookerStudioPermissionsListCmd struct {
+ AssetID string `arg:"" name:"asset-id" help:"Looker Studio asset ID (Drive file ID)"`
+ User string `name:"user" help:"User email to list permissions as"`
+}
+
+func (c *LookerStudioPermissionsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(c.User) != "" {
+ account = strings.TrimSpace(c.User)
+ }
+
+ assetID := strings.TrimSpace(c.AssetID)
+ if assetID == "" {
+ return usage("asset-id is required")
+ }
+
+ svc, err := newDriveService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Permissions.List(assetID).SupportsAllDrives(true).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list permissions: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Permissions) == 0 {
+ u.Err().Println("No permissions found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
+ for _, perm := range resp.Permissions {
+ if perm == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(perm.Id),
+ sanitizeTab(perm.Type),
+ sanitizeTab(perm.Role),
+ sanitizeTab(perm.EmailAddress),
+ )
+ }
+ return nil
+}
+
+type LookerStudioPermissionsAddCmd struct {
+ AssetID string `arg:"" name:"asset-id" help:"Looker Studio asset ID (Drive file ID)"`
+ User string `name:"user" help:"User email to apply permission as"`
+ Email string `name:"email" help:"User email to grant access" required:""`
+ Role string `name:"role" help:"Permission role: VIEWER|EDITOR" default:"VIEWER"`
+}
+
+func (c *LookerStudioPermissionsAddCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(c.User) != "" {
+ account = strings.TrimSpace(c.User)
+ }
+
+ assetID := strings.TrimSpace(c.AssetID)
+ if assetID == "" {
+ return usage("asset-id is required")
+ }
+ email := strings.TrimSpace(c.Email)
+ if email == "" {
+ return usage("--email is required")
+ }
+
+ role := strings.ToUpper(strings.TrimSpace(c.Role))
+ var driveRole string
+ switch role {
+ case "VIEWER":
+ driveRole = "reader"
+ case "EDITOR":
+ driveRole = "writer"
+ default:
+ return usage("invalid --role (expected VIEWER|EDITOR)")
+ }
+
+ svc, err := newDriveService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ perm := &drive.Permission{Type: "user", Role: driveRole, EmailAddress: email}
+ created, err := svc.Permissions.Create(assetID, perm).
+ SupportsAllDrives(true).
+ SendNotificationEmail(false).
+ Fields("id, type, role, emailAddress").
+ Context(ctx).
+ Do()
+ if err != nil {
+ return fmt.Errorf("add permission: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Added permission: %s (%s)\n", created.Id, email)
+ return nil
+}
+
+type LookerStudioPermissionsRemoveCmd struct {
+ AssetID string `arg:"" name:"asset-id" help:"Looker Studio asset ID (Drive file ID)"`
+ PermissionID string `arg:"" name:"permission-id" help:"Permission ID"`
+ User string `name:"user" help:"User email to apply permission as"`
+}
+
+func (c *LookerStudioPermissionsRemoveCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(c.User) != "" {
+ account = strings.TrimSpace(c.User)
+ }
+
+ assetID := strings.TrimSpace(c.AssetID)
+ permissionID := strings.TrimSpace(c.PermissionID)
+ if assetID == "" || permissionID == "" {
+ return usage("asset-id and permission-id are required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from asset %s", permissionID, assetID)); err != nil {
+ return err
+ }
+
+ svc, err := newDriveService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Permissions.Delete(assetID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("remove permission: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"removed": true, "permissionId": permissionID})
+ }
+
+ u.Out().Printf("Removed permission: %s\n", permissionID)
+ return nil
+}
diff --git a/internal/cmd/lookerstudio_test.go b/internal/cmd/lookerstudio_test.go
new file mode 100644
index 00000000..93e66174
--- /dev/null
+++ b/internal/cmd/lookerstudio_test.go
@@ -0,0 +1,115 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestLookerStudioPermissionsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/asset1/permissions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "permissions": []map[string]any{
+ {"id": "perm1", "type": "user", "role": "reader", "emailAddress": "viewer@example.com"},
+ },
+ })
+ })
+ stubDrive(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &LookerStudioPermissionsListCmd{AssetID: "asset1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "viewer@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLookerStudioPermissionsAddCmd(t *testing.T) {
+ var gotRole string
+ var gotEmail string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/files/asset1/permissions") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ Role string `json:"role"`
+ Type string `json:"type"`
+ Email string `json:"emailAddress"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotRole = payload.Role
+ gotEmail = payload.Email
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "perm1", "role": payload.Role, "emailAddress": payload.Email})
+ })
+ stubDrive(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &LookerStudioPermissionsAddCmd{AssetID: "asset1", Email: "editor@example.com", Role: "EDITOR"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotRole != "writer" || gotEmail != "editor@example.com" {
+ t.Fatalf("unexpected permission payload: role=%q email=%q", gotRole, gotEmail)
+ }
+ if !strings.Contains(out, "Added permission") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLookerStudioPermissionsRemoveCmd(t *testing.T) {
+ var deleted bool
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/files/asset1/permissions/perm1") {
+ http.NotFound(w, r)
+ return
+ }
+ deleted = true
+ w.WriteHeader(http.StatusNoContent)
+ })
+ stubDrive(t, h)
+
+ flags := &RootFlags{Account: "user@example.com", Force: true}
+ cmd := &LookerStudioPermissionsRemoveCmd{AssetID: "asset1", PermissionID: "perm1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !deleted {
+ t.Fatalf("expected delete request")
+ }
+ if !strings.Contains(out, "Removed permission") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestLookerStudioPermissionsAddCmd_BadRole(t *testing.T) {
+ cmd := &LookerStudioPermissionsAddCmd{AssetID: "asset1", Email: "user@example.com", Role: "OWNER"}
+ if err := cmd.Run(context.Background(), &RootFlags{Account: "user@example.com"}); err == nil {
+ t.Fatalf("expected error")
+ }
+}
diff --git a/internal/cmd/meet.go b/internal/cmd/meet.go
new file mode 100644
index 00000000..3be2ba77
--- /dev/null
+++ b/internal/cmd/meet.go
@@ -0,0 +1,209 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newMeetService = googleapi.NewMeet
+
+type MeetCmd struct {
+ Spaces MeetSpacesCmd `cmd:"" name:"spaces" help:"Manage Meet spaces"`
+}
+
+type MeetSpacesCmd struct {
+ List MeetSpacesListCmd `cmd:"" name:"list" aliases:"ls" help:"List meeting spaces"`
+ Get MeetSpacesGetCmd `cmd:"" name:"get" help:"Get meeting space"`
+ Create MeetSpacesCreateCmd `cmd:"" name:"create" help:"Create meeting space"`
+ End MeetSpacesEndCmd `cmd:"" name:"end" help:"End active conference in space"`
+}
+
+type MeetSpacesListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *MeetSpacesListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newMeetService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.ConferenceRecords.List()
+ if c.Max > 0 {
+ call = call.PageSize(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list conference records: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.ConferenceRecords) == 0 {
+ u.Err().Println("No meeting spaces found")
+ return nil
+ }
+
+ latest := make(map[string]*meet.ConferenceRecord)
+ for _, record := range resp.ConferenceRecords {
+ if record == nil || record.Space == "" {
+ continue
+ }
+ if cur, ok := latest[record.Space]; !ok || cur.StartTime < record.StartTime {
+ latest[record.Space] = record
+ }
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "SPACE\tLAST START\tLAST END")
+ for space, record := range latest {
+ endTime := ""
+ if record != nil {
+ endTime = record.EndTime
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", sanitizeTab(space), sanitizeTab(record.StartTime), sanitizeTab(endTime))
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type MeetSpacesGetCmd struct {
+ Space string `arg:"" name:"space" help:"Space name or meeting code"`
+}
+
+func (c *MeetSpacesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ space := strings.TrimSpace(c.Space)
+ if space == "" {
+ return usage("space is required")
+ }
+ if !strings.HasPrefix(space, "spaces/") {
+ space = "spaces/" + space
+ }
+
+ svc, err := newMeetService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Spaces.Get(space).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get space %s: %w", space, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Name: %s\n", resp.Name)
+ u.Out().Printf("Meeting Code: %s\n", resp.MeetingCode)
+ u.Out().Printf("Meeting URI: %s\n", resp.MeetingUri)
+ if resp.Config != nil {
+ u.Out().Printf("Access Type: %s\n", resp.Config.AccessType)
+ }
+ return nil
+}
+
+type MeetSpacesCreateCmd struct {
+ AccessType string `name:"access-type" help:"Access type: OPEN|TRUSTED|RESTRICTED"`
+}
+
+func (c *MeetSpacesCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ var cfg *meet.SpaceConfig
+ if strings.TrimSpace(c.AccessType) != "" {
+ accessType := strings.ToUpper(strings.TrimSpace(c.AccessType))
+ switch accessType {
+ case "OPEN", "TRUSTED", "RESTRICTED":
+ cfg = &meet.SpaceConfig{AccessType: accessType}
+ default:
+ return usage("invalid --access-type (expected OPEN|TRUSTED|RESTRICTED)")
+ }
+ }
+
+ svc, err := newMeetService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ space := &meet.Space{Config: cfg}
+ created, err := svc.Spaces.Create(space).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create space: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created space: %s (%s)\n", created.Name, created.MeetingUri)
+ return nil
+}
+
+type MeetSpacesEndCmd struct {
+ Space string `arg:"" name:"space" help:"Space name or meeting code"`
+}
+
+func (c *MeetSpacesEndCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ space := strings.TrimSpace(c.Space)
+ if space == "" {
+ return usage("space is required")
+ }
+ if !strings.HasPrefix(space, "spaces/") {
+ space = "spaces/" + space
+ }
+
+ svc, err := newMeetService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if _, err := svc.Spaces.EndActiveConference(space, &meet.EndActiveConferenceRequest{}).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("end active conference: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"space": space, "ended": true})
+ }
+
+ u.Out().Printf("Ended active conference in %s\n", space)
+ return nil
+}
diff --git a/internal/cmd/meet_test.go b/internal/cmd/meet_test.go
new file mode 100644
index 00000000..28a2ac18
--- /dev/null
+++ b/internal/cmd/meet_test.go
@@ -0,0 +1,638 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/meet/v2"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// Helper functions
+
+func testMeetContext(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func testMeetContextWithStdout(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func stubMeet(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newMeetService
+ svc, err := meet.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new meet service: %v", err)
+ }
+ newMeetService = func(context.Context, string) (*meet.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newMeetService = orig
+ srv.Close()
+ })
+}
+
+// MeetSpacesListCmd tests
+
+func TestMeetSpacesListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "conferenceRecords": []map[string]any{
+ {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T00:00:00Z"},
+ },
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextJSON(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var parsed struct {
+ ConferenceRecords []struct {
+ Name string `json:"name"`
+ Space string `json:"space"`
+ StartTime string `json:"startTime"`
+ } `json:"conferenceRecords"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(parsed.ConferenceRecords) != 1 {
+ t.Fatalf("expected 1 record, got %d", len(parsed.ConferenceRecords))
+ }
+ if parsed.ConferenceRecords[0].Space != "spaces/space1" {
+ t.Fatalf("unexpected space: %s", parsed.ConferenceRecords[0].Space)
+ }
+}
+
+func TestMeetSpacesListCmd_EmptyResults(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "conferenceRecords": []map[string]any{},
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesListCmd{}
+
+ // Empty results should not error, just show message on stderr
+ if err := cmd.Run(testMeetContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestMeetSpacesListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "conferenceRecords": []map[string]any{
+ {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T00:00:00Z"},
+ },
+ "nextPageToken": "token123",
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesListCmd{}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed["nextPageToken"] != "token123" {
+ t.Fatalf("unexpected nextPageToken: %v", parsed["nextPageToken"])
+ }
+}
+
+func TestMeetSpacesListCmd_WithPaging(t *testing.T) {
+ var gotPageSize, gotPageToken string
+
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPageSize = r.URL.Query().Get("pageSize")
+ gotPageToken = r.URL.Query().Get("pageToken")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "conferenceRecords": []map[string]any{
+ {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T00:00:00Z"},
+ },
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesListCmd{Max: 25, Page: "mytoken"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testMeetContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPageSize != "25" {
+ t.Fatalf("unexpected pageSize: %q", gotPageSize)
+ }
+ if gotPageToken != "mytoken" {
+ t.Fatalf("unexpected pageToken: %q", gotPageToken)
+ }
+}
+
+func TestMeetSpacesListCmd_DeduplicatesSpaces(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "conferenceRecords": []map[string]any{
+ {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T10:00:00Z", "endTime": "2026-01-01T11:00:00Z"},
+ {"name": "conferenceRecords/2", "space": "spaces/space1", "startTime": "2026-01-02T10:00:00Z", "endTime": "2026-01-02T11:00:00Z"},
+ {"name": "conferenceRecords/3", "space": "spaces/space2", "startTime": "2026-01-01T10:00:00Z"},
+ },
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testMeetContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should show each space once with the latest record
+ space1Count := strings.Count(out, "spaces/space1")
+ space2Count := strings.Count(out, "spaces/space2")
+ if space1Count != 1 || space2Count != 1 {
+ t.Fatalf("expected each space once, got space1=%d space2=%d in output=%q", space1Count, space2Count, out)
+ }
+
+ // Should show the later date for space1 (2026-01-02)
+ if !strings.Contains(out, "2026-01-02") {
+ t.Fatalf("expected newer date for space1, got=%q", out)
+ }
+}
+
+// MeetSpacesGetCmd tests
+
+func TestMeetSpacesGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/spaces/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "spaces/abc123",
+ "meetingCode": "abc-defg-hij",
+ "meetingUri": "https://meet.google.com/abc-defg-hij",
+ "config": map[string]any{
+ "accessType": "TRUSTED",
+ },
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesGetCmd{Space: "abc123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "spaces/abc123") || !strings.Contains(out, "abc-defg-hij") || !strings.Contains(out, "TRUSTED") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestMeetSpacesGetCmd_EmptySpace(t *testing.T) {
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesGetCmd{Space: ""}
+
+ err := cmd.Run(testMeetContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty space")
+ }
+ if !strings.Contains(err.Error(), "space is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMeetSpacesGetCmd_WithSpacePrefix(t *testing.T) {
+ var gotPath string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/spaces/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "spaces/abc123",
+ "meetingCode": "abc-defg-hij",
+ "meetingUri": "https://meet.google.com/abc-defg-hij",
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesGetCmd{Space: "spaces/abc123"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should not double the prefix
+ if strings.Contains(gotPath, "spaces/spaces/") {
+ t.Fatalf("unexpected double prefix in path: %q", gotPath)
+ }
+}
+
+func TestMeetSpacesGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/spaces/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "spaces/abc123",
+ "meetingCode": "abc-defg-hij",
+ "meetingUri": "https://meet.google.com/abc-defg-hij",
+ "config": map[string]any{
+ "accessType": "OPEN",
+ },
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesGetCmd{Space: "abc123"}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed["name"] != "spaces/abc123" {
+ t.Fatalf("unexpected name: %v", parsed["name"])
+ }
+}
+
+// MeetSpacesCreateCmd tests
+
+func TestMeetSpacesCreateCmd(t *testing.T) {
+ var gotAccess string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ Config struct {
+ AccessType string `json:"accessType"`
+ } `json:"config"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotAccess = payload.Config.AccessType
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "spaces/space1", "meetingUri": "https://meet.google.com/abc-defg-hij"})
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesCreateCmd{AccessType: "OPEN"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotAccess != "OPEN" {
+ t.Fatalf("unexpected access type: %q", gotAccess)
+ }
+ if !strings.Contains(out, "Created space") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestMeetSpacesCreateCmd_NoAccessType(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "spaces/newspace",
+ "meetingCode": "new-meet-ing",
+ "meetingUri": "https://meet.google.com/new-meet-ing",
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesCreateCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextJSON(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var parsed struct {
+ Name string `json:"name"`
+ MeetingCode string `json:"meetingCode"`
+ MeetingURI string `json:"meetingUri"`
+ }
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed.Name != "spaces/newspace" {
+ t.Fatalf("unexpected name: %s", parsed.Name)
+ }
+}
+
+func TestMeetSpacesCreateCmd_InvalidAccessType(t *testing.T) {
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesCreateCmd{AccessType: "INVALID"}
+
+ err := cmd.Run(testMeetContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for invalid access type")
+ }
+ if !strings.Contains(err.Error(), "invalid --access-type") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMeetSpacesCreateCmd_AllAccessTypes(t *testing.T) {
+ for _, accessType := range []string{"OPEN", "TRUSTED", "RESTRICTED", "open", "trusted", "restricted"} {
+ t.Run(accessType, func(t *testing.T) {
+ var gotAccess string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ Config struct {
+ AccessType string `json:"accessType"`
+ } `json:"config"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotAccess = payload.Config.AccessType
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "spaces/space1",
+ "meetingUri": "https://meet.google.com/abc-defg-hij",
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesCreateCmd{AccessType: accessType}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ expected := strings.ToUpper(accessType)
+ if gotAccess != expected {
+ t.Fatalf("expected access type %q, got %q", expected, gotAccess)
+ }
+ })
+ }
+}
+
+func TestMeetSpacesCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "spaces/newspace",
+ "meetingCode": "new-meet-ing",
+ "meetingUri": "https://meet.google.com/new-meet-ing",
+ })
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesCreateCmd{}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed["name"] != "spaces/newspace" {
+ t.Fatalf("unexpected name: %v", parsed["name"])
+ }
+}
+
+// MeetSpacesEndCmd tests
+
+func TestMeetSpacesEndCmd(t *testing.T) {
+ var ended bool
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces/space1:endActiveConference") {
+ http.NotFound(w, r)
+ return
+ }
+ ended = true
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesEndCmd{Space: "space1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !ended {
+ t.Fatalf("expected end request")
+ }
+ if !strings.Contains(out, "Ended active conference") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestMeetSpacesEndCmd_EmptySpace(t *testing.T) {
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesEndCmd{Space: ""}
+
+ err := cmd.Run(testMeetContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty space")
+ }
+ if !strings.Contains(err.Error(), "space is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMeetSpacesEndCmd_WhitespaceOnlySpace(t *testing.T) {
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesEndCmd{Space: " "}
+
+ err := cmd.Run(testMeetContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for whitespace-only space")
+ }
+ if !strings.Contains(err.Error(), "space is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMeetSpacesEndCmd_WithSpacePrefix(t *testing.T) {
+ var gotPath string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":endActiveConference") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesEndCmd{Space: "spaces/abc123"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should not double the prefix
+ if strings.Contains(gotPath, "spaces/spaces/") {
+ t.Fatalf("unexpected double prefix in path: %q", gotPath)
+ }
+}
+
+func TestMeetSpacesEndCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":endActiveConference") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubMeet(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesEndCmd{Space: "abc123"}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if parsed["space"] != "spaces/abc123" {
+ t.Fatalf("unexpected space: %v", parsed["space"])
+ }
+ if parsed["ended"] != true {
+ t.Fatalf("unexpected ended: %v", parsed["ended"])
+ }
+}
diff --git a/internal/cmd/orgunits.go b/internal/cmd/orgunits.go
new file mode 100644
index 00000000..68841497
--- /dev/null
+++ b/internal/cmd/orgunits.go
@@ -0,0 +1,9 @@
+package cmd
+
+type OrgunitsCmd struct {
+ List OrgunitsListCmd `cmd:"" name:"list" aliases:"ls" help:"List organizational units"`
+ Get OrgunitsGetCmd `cmd:"" name:"get" help:"Get organizational unit"`
+ Create OrgunitsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create organizational unit"`
+ Update OrgunitsUpdateCmd `cmd:"" name:"update" help:"Update organizational unit"`
+ Delete OrgunitsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete organizational unit"`
+}
diff --git a/internal/cmd/orgunits_create.go b/internal/cmd/orgunits_create.go
new file mode 100644
index 00000000..cf68c475
--- /dev/null
+++ b/internal/cmd/orgunits_create.go
@@ -0,0 +1,55 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type OrgunitsCreateCmd struct {
+ Name string `arg:"" name:"name" help:"Org unit name"`
+ Parent string `name:"parent" help:"Parent org unit path"`
+ Description string `name:"description" help:"Description"`
+}
+
+func (c *OrgunitsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ parent := strings.TrimSpace(c.Parent)
+ if parent == "" {
+ parent = "/"
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ org := &admin.OrgUnit{
+ Name: c.Name,
+ ParentOrgUnitPath: parent,
+ Description: c.Description,
+ }
+
+ created, err := svc.Orgunits.Insert(adminCustomerID(), org).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create org unit: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created org unit: %s (%s)\n", created.Name, created.OrgUnitPath)
+ return nil
+}
diff --git a/internal/cmd/orgunits_delete.go b/internal/cmd/orgunits_delete.go
new file mode 100644
index 00000000..8f1d8bcd
--- /dev/null
+++ b/internal/cmd/orgunits_delete.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type OrgunitsDeleteCmd struct {
+ Path string `arg:"" name:"path" help:"Org unit path or ID"`
+}
+
+func (c *OrgunitsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete org unit %s", c.Path)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Orgunits.Delete(adminCustomerID(), c.Path).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete org unit %s: %w", c.Path, err)
+ }
+
+ u.Out().Printf("Deleted org unit: %s\n", c.Path)
+ return nil
+}
diff --git a/internal/cmd/orgunits_get.go b/internal/cmd/orgunits_get.go
new file mode 100644
index 00000000..27b15540
--- /dev/null
+++ b/internal/cmd/orgunits_get.go
@@ -0,0 +1,50 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type OrgunitsGetCmd struct {
+ Path string `arg:"" name:"path" help:"Org unit path or ID"`
+}
+
+func (c *OrgunitsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ ou, err := svc.Orgunits.Get(adminCustomerID(), c.Path).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get org unit %s: %w", c.Path, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, ou)
+ }
+
+ u.Out().Printf("Name: %s\n", ou.Name)
+ u.Out().Printf("Path: %s\n", ou.OrgUnitPath)
+ u.Out().Printf("ID: %s\n", ou.OrgUnitId)
+ if ou.ParentOrgUnitPath != "" {
+ u.Out().Printf("Parent Path: %s\n", ou.ParentOrgUnitPath)
+ }
+ if ou.ParentOrgUnitId != "" {
+ u.Out().Printf("Parent ID: %s\n", ou.ParentOrgUnitId)
+ }
+ if ou.Description != "" {
+ u.Out().Printf("Description: %s\n", ou.Description)
+ }
+ return nil
+}
diff --git a/internal/cmd/orgunits_list.go b/internal/cmd/orgunits_list.go
new file mode 100644
index 00000000..27f87099
--- /dev/null
+++ b/internal/cmd/orgunits_list.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type OrgunitsListCmd struct {
+ Parent string `name:"parent" help:"Parent org unit path (default: /)"`
+ Type string `name:"type" default:"children" enum:"all,children,allIncludingParent" help:"Whether to return all descendants or immediate children"`
+ ToDrive ToDriveFlags `embed:""`
+}
+
+func (c *OrgunitsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ parent := strings.TrimSpace(c.Parent)
+ if parent == "" {
+ parent = "/"
+ }
+
+ resp, err := svc.Orgunits.List(adminCustomerID()).
+ OrgUnitPath(parent).
+ Type(c.Type).
+ Context(ctx).
+ Do()
+ if err != nil {
+ return fmt.Errorf("list org units: %w", err)
+ }
+
+ if len(resp.OrganizationUnits) == 0 {
+ u.Err().Println("No organizational units found")
+ return nil
+ }
+
+ rows := make([][]string, 0, len(resp.OrganizationUnits))
+ for _, ou := range resp.OrganizationUnits {
+ if ou == nil {
+ continue
+ }
+ rows = append(rows, toDriveRow(
+ ou.OrgUnitPath,
+ ou.Name,
+ ou.OrgUnitId,
+ ou.Description,
+ ))
+ }
+
+ if ok, err := writeToDrive(ctx, flags, toDriveTitle("Org Units", c.ToDrive), []string{"PATH", "NAME", "ID", "DESCRIPTION"}, rows, c.ToDrive); ok {
+ return err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "PATH\tNAME\tID\tDESCRIPTION")
+ for _, row := range rows {
+ if len(row) < 4 {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(row[0]),
+ sanitizeTab(row[1]),
+ sanitizeTab(row[2]),
+ sanitizeTab(row[3]),
+ )
+ }
+ return nil
+}
diff --git a/internal/cmd/orgunits_test.go b/internal/cmd/orgunits_test.go
new file mode 100644
index 00000000..865b32da
--- /dev/null
+++ b/internal/cmd/orgunits_test.go
@@ -0,0 +1,805 @@
+package cmd
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func TestOrgunitsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "organizationUnits": []map[string]any{
+ {"name": "Sales", "orgUnitPath": "/Sales", "orgUnitId": "ou-1"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Sales") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestOrgunitsListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "organizationUnits": []map[string]any{
+ {"name": "Engineering", "orgUnitPath": "/Engineering", "orgUnitId": "ou-2", "description": "Engineering team"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "organizationUnits") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+ if !strings.Contains(out, "Engineering") {
+ t.Fatalf("expected Engineering in output, got: %s", out)
+ }
+}
+
+func TestOrgunitsListCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "organizationUnits": []map[string]any{},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsListCmd{}
+
+ // Should not error on empty results
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestOrgunitsListCmd_WithParent(t *testing.T) {
+ var capturedPath string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ capturedPath = r.URL.Query().Get("orgUnitPath")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "organizationUnits": []map[string]any{
+ {"name": "West", "orgUnitPath": "/Sales/West", "orgUnitId": "ou-3"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsListCmd{Parent: "/Sales"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if capturedPath != "/Sales" {
+ t.Errorf("expected parent path /Sales, got %s", capturedPath)
+ }
+ if !strings.Contains(out, "West") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestOrgunitsGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "Sales",
+ "orgUnitPath": "/Sales",
+ "orgUnitId": "ou-1",
+ "parentOrgUnitPath": "/",
+ "parentOrgUnitId": "root-ou",
+ "description": "Sales department",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsGetCmd{Path: "/Sales"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Name:") || !strings.Contains(out, "Sales") {
+ t.Fatalf("expected Name: Sales in output, got: %s", out)
+ }
+ if !strings.Contains(out, "Path:") || !strings.Contains(out, "/Sales") {
+ t.Fatalf("expected Path: /Sales in output, got: %s", out)
+ }
+ if !strings.Contains(out, "Description:") || !strings.Contains(out, "Sales department") {
+ t.Fatalf("expected Description in output, got: %s", out)
+ }
+}
+
+func TestOrgunitsGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "Engineering",
+ "orgUnitPath": "/Engineering",
+ "orgUnitId": "ou-2",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsGetCmd{Path: "/Engineering"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"orgUnitPath"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestOrgunitsGetCmd_MinimalFields(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ // Response with minimal fields - no parent, no description
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "Simple",
+ "orgUnitPath": "/Simple",
+ "orgUnitId": "ou-simple",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsGetCmd{Path: "/Simple"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Simple") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ // Should NOT contain description if empty
+ if strings.Contains(out, "Description:") {
+ t.Errorf("should not show empty description, got: %s", out)
+ }
+}
+
+func TestOrgunitsGetCmd_Error(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsGetCmd{Path: "/NonExistent"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for non-existent org unit")
+ }
+ if !strings.Contains(err.Error(), "get org unit") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+func TestOrgunitsCreateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "NewUnit",
+ "orgUnitPath": "/NewUnit",
+ "orgUnitId": "ou-new",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsCreateCmd{Name: "NewUnit"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created org unit") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "NewUnit") {
+ t.Fatalf("expected NewUnit in output, got: %s", out)
+ }
+}
+
+func TestOrgunitsCreateCmd_WithParent(t *testing.T) {
+ var capturedBody map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewDecoder(r.Body).Decode(&capturedBody)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "SubUnit",
+ "orgUnitPath": "/Sales/SubUnit",
+ "orgUnitId": "ou-sub",
+ "parentOrgUnitPath": "/Sales",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsCreateCmd{Name: "SubUnit", Parent: "/Sales"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if capturedBody["parentOrgUnitPath"] != "/Sales" {
+ t.Errorf("expected parent /Sales, got %v", capturedBody["parentOrgUnitPath"])
+ }
+ if !strings.Contains(out, "SubUnit") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestOrgunitsCreateCmd_WithDescription(t *testing.T) {
+ var capturedBody map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewDecoder(r.Body).Decode(&capturedBody)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "Marketing",
+ "orgUnitPath": "/Marketing",
+ "orgUnitId": "ou-mkt",
+ "description": "Marketing department",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsCreateCmd{Name: "Marketing", Description: "Marketing department"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if capturedBody["description"] != "Marketing department" {
+ t.Errorf("expected description to be passed, got %v", capturedBody["description"])
+ }
+ if !strings.Contains(out, "Marketing") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestOrgunitsCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "JSONUnit",
+ "orgUnitPath": "/JSONUnit",
+ "orgUnitId": "ou-json",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsCreateCmd{Name: "JSONUnit"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"orgUnitPath"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestOrgunitsCreateCmd_DefaultParent(t *testing.T) {
+ var capturedBody map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewDecoder(r.Body).Decode(&capturedBody)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "RootChild",
+ "orgUnitPath": "/RootChild",
+ "orgUnitId": "ou-root-child",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsCreateCmd{Name: "RootChild", Parent: ""} // Empty parent
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ // Should default to root "/"
+ if capturedBody["parentOrgUnitPath"] != "/" {
+ t.Errorf("expected default parent /, got %v", capturedBody["parentOrgUnitPath"])
+ }
+}
+
+func TestOrgunitsCreateCmd_Error(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "already exists", http.StatusConflict)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsCreateCmd{Name: "Existing"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for conflict")
+ }
+ if !strings.Contains(err.Error(), "create org unit") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+func TestOrgunitsUpdateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "UpdatedName",
+ "orgUnitPath": "/Sales",
+ "orgUnitId": "ou-1",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "UpdatedName"
+ cmd := &OrgunitsUpdateCmd{Path: "/Sales", Name: &newName}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Updated org unit") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "UpdatedName") {
+ t.Fatalf("expected UpdatedName in output, got: %s", out)
+ }
+}
+
+func TestOrgunitsUpdateCmd_WithDescription(t *testing.T) {
+ var capturedBody map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewDecoder(r.Body).Decode(&capturedBody)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "Sales",
+ "orgUnitPath": "/Sales",
+ "orgUnitId": "ou-1",
+ "description": "Updated description",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ desc := "Updated description"
+ cmd := &OrgunitsUpdateCmd{Path: "/Sales", Description: &desc}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if capturedBody["description"] != "Updated description" {
+ t.Errorf("expected description update, got %v", capturedBody["description"])
+ }
+}
+
+func TestOrgunitsUpdateCmd_WithParent(t *testing.T) {
+ var capturedBody map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewDecoder(r.Body).Decode(&capturedBody)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "West",
+ "orgUnitPath": "/Marketing/West",
+ "orgUnitId": "ou-west",
+ "parentOrgUnitPath": "/Marketing",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newParent := "/Marketing"
+ cmd := &OrgunitsUpdateCmd{Path: "/Sales/West", Parent: &newParent}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if capturedBody["parentOrgUnitPath"] != "/Marketing" {
+ t.Errorf("expected parent update, got %v", capturedBody["parentOrgUnitPath"])
+ }
+}
+
+func TestOrgunitsUpdateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "JSONUpdate",
+ "orgUnitPath": "/JSONUpdate",
+ "orgUnitId": "ou-json",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "JSONUpdate"
+ cmd := &OrgunitsUpdateCmd{Path: "/OldName", Name: &newName}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"orgUnitPath"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestOrgunitsUpdateCmd_NoUpdates(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Should not be called
+ t.Error("API should not be called when no updates specified")
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsUpdateCmd{Path: "/Sales"} // No Name, Parent, or Description
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error when no updates specified")
+ }
+ var exitErr *ExitError
+ if !errors.As(err, &exitErr) || exitErr.Code != 2 {
+ t.Errorf("expected usage error (exit code 2), got %v", err)
+ }
+}
+
+func TestOrgunitsUpdateCmd_ClearDescription(t *testing.T) {
+ var capturedBody map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewDecoder(r.Body).Decode(&capturedBody)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "Sales",
+ "orgUnitPath": "/Sales",
+ "orgUnitId": "ou-1",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ emptyDesc := ""
+ cmd := &OrgunitsUpdateCmd{Path: "/Sales", Description: &emptyDesc}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ // The empty string should be explicitly sent
+ if capturedBody["description"] != "" {
+ t.Errorf("expected empty description to be sent, got %v", capturedBody["description"])
+ }
+}
+
+func TestOrgunitsUpdateCmd_Error(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "NewName"
+ cmd := &OrgunitsUpdateCmd{Path: "/NonExistent", Name: &newName}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for non-existent org unit")
+ }
+ if !strings.Contains(err.Error(), "update org unit") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+func TestOrgunitsDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &OrgunitsDeleteCmd{Path: "/OldUnit"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted org unit") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "/OldUnit") {
+ t.Fatalf("expected path in output, got: %s", out)
+ }
+}
+
+func TestOrgunitsDeleteCmd_RequiresForce(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Should not be called without Force flag
+ t.Error("API should not be called without Force flag")
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", NoInput: true} // No Force, non-interactive
+ cmd := &OrgunitsDeleteCmd{Path: "/OldUnit"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error without --force")
+ }
+ var exitErr *ExitError
+ if !errors.As(err, &exitErr) {
+ t.Errorf("expected ExitError, got %v", err)
+ }
+}
+
+func TestOrgunitsDeleteCmd_Error(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &OrgunitsDeleteCmd{Path: "/NonExistent"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for non-existent org unit")
+ }
+ if !strings.Contains(err.Error(), "delete org unit") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+func TestOrgunitsDeleteCmd_ByID(t *testing.T) {
+ var capturedPath string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Capture the path part after /orgunits/
+ parts := strings.Split(r.URL.Path, "/orgunits/")
+ if len(parts) > 1 {
+ capturedPath = parts[1]
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &OrgunitsDeleteCmd{Path: "id:ou-12345"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // The path should be passed through (org unit ID format)
+ if !strings.Contains(capturedPath, "id:ou-12345") && !strings.Contains(capturedPath, "ou-12345") {
+ t.Errorf("expected org unit ID to be passed, got path: %s", capturedPath)
+ }
+ if !strings.Contains(out, "Deleted org unit") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestOrgunitsListCmd_Error(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "permission denied", http.StatusForbidden)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for permission denied")
+ }
+ if !strings.Contains(err.Error(), "list org units") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+func TestOrgunitsListCmd_WithType(t *testing.T) {
+ var capturedType string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") {
+ http.NotFound(w, r)
+ return
+ }
+ capturedType = r.URL.Query().Get("type")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "organizationUnits": []map[string]any{
+ {"name": "All1", "orgUnitPath": "/All1", "orgUnitId": "ou-all1"},
+ {"name": "All2", "orgUnitPath": "/All1/All2", "orgUnitId": "ou-all2"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &OrgunitsListCmd{Type: "all"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if capturedType != "all" {
+ t.Errorf("expected type=all, got %s", capturedType)
+ }
+ if !strings.Contains(out, "All1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+// Test that requireAccount returns error when no account is provided
+func TestOrgunitsCmd_RequiresAccount(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Should not be called
+ t.Error("API should not be called without account")
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{} // No account specified
+ cmd := &OrgunitsListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error when account not specified")
+ }
+}
diff --git a/internal/cmd/orgunits_update.go b/internal/cmd/orgunits_update.go
new file mode 100644
index 00000000..38b02c8e
--- /dev/null
+++ b/internal/cmd/orgunits_update.go
@@ -0,0 +1,67 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type OrgunitsUpdateCmd struct {
+ Path string `arg:"" name:"path" help:"Org unit path or ID"`
+ Name *string `name:"name" help:"New org unit name"`
+ Parent *string `name:"parent" help:"New parent org unit path"`
+ Description *string `name:"description" help:"Description"`
+}
+
+func (c *OrgunitsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ org := &admin.OrgUnit{}
+ hasUpdates := false
+
+ if c.Name != nil {
+ org.Name = *c.Name
+ hasUpdates = true
+ }
+ if c.Parent != nil {
+ org.ParentOrgUnitPath = *c.Parent
+ hasUpdates = true
+ }
+ if c.Description != nil {
+ org.Description = *c.Description
+ if *c.Description == "" {
+ org.ForceSendFields = append(org.ForceSendFields, "Description")
+ }
+ hasUpdates = true
+ }
+
+ if !hasUpdates {
+ return usage("no updates specified")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ updated, err := svc.Orgunits.Update(adminCustomerID(), c.Path, org).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update org unit %s: %w", c.Path, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated org unit: %s (%s)\n", updated.Name, updated.OrgUnitPath)
+ return nil
+}
diff --git a/internal/cmd/printers.go b/internal/cmd/printers.go
new file mode 100644
index 00000000..e4395a96
--- /dev/null
+++ b/internal/cmd/printers.go
@@ -0,0 +1,287 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type PrintersCmd struct {
+ List PrintersListCmd `cmd:"" name:"list" aliases:"ls" help:"List printers"`
+ Get PrintersGetCmd `cmd:"" name:"get" help:"Get printer"`
+ Create PrintersCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create printer"`
+ Update PrintersUpdateCmd `cmd:"" name:"update" help:"Update printer"`
+ Delete PrintersDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete printer"`
+}
+
+type PrintersListCmd struct {
+ OrgUnit string `name:"org-unit" aliases:"ou" help:"Filter by org unit path or ID"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *PrintersListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ parent := printerParent()
+ call := svc.Customers.Chrome.Printers.List(parent)
+ if c.Max > 0 {
+ call = call.PageSize(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ if strings.TrimSpace(c.OrgUnit) != "" {
+ orgUnit := strings.TrimSpace(c.OrgUnit)
+ orgUnit = strings.TrimPrefix(orgUnit, "orgUnits/")
+ var orgUnitID string
+ orgUnitID, err = resolveOrgUnitID(ctx, svc, orgUnit)
+ if err != nil {
+ return err
+ }
+ call = call.OrgUnitId(orgUnitID)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list printers: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Printers) == 0 {
+ u.Err().Println("No printers found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ID\tNAME\tURI\tORG UNIT")
+ for _, printer := range resp.Printers {
+ if printer == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(printer.Id),
+ sanitizeTab(printer.DisplayName),
+ sanitizeTab(printer.Uri),
+ sanitizeTab(printer.OrgUnitId),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type PrintersGetCmd struct {
+ PrinterID string `arg:"" name:"printer-id" help:"Printer ID"`
+}
+
+func (c *PrintersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ printerID := strings.TrimSpace(c.PrinterID)
+ if printerID == "" {
+ return usage("printer ID is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ name := printerResourceName(printerID)
+ printer, err := svc.Customers.Chrome.Printers.Get(name).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get printer %s: %w", printerID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, printer)
+ }
+
+ u.Out().Printf("ID: %s\n", printer.Id)
+ u.Out().Printf("Name: %s\n", printer.DisplayName)
+ u.Out().Printf("URI: %s\n", printer.Uri)
+ if printer.OrgUnitId != "" {
+ u.Out().Printf("Org Unit: %s\n", printer.OrgUnitId)
+ }
+ if printer.Description != "" {
+ u.Out().Printf("Desc: %s\n", printer.Description)
+ }
+ return nil
+}
+
+type PrintersCreateCmd struct {
+ Name string `name:"name" help:"Printer name" required:""`
+ URI string `name:"uri" help:"Printer URI" required:""`
+ OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path or ID"`
+}
+
+func (c *PrintersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ uri := strings.TrimSpace(c.URI)
+ if name == "" || uri == "" {
+ return usage("--name and --uri are required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ printer := &admin.Printer{
+ DisplayName: name,
+ Uri: uri,
+ }
+ if strings.TrimSpace(c.OrgUnit) != "" {
+ orgUnit := strings.TrimSpace(c.OrgUnit)
+ orgUnit = strings.TrimPrefix(orgUnit, "orgUnits/")
+ var orgUnitID string
+ orgUnitID, err = resolveOrgUnitID(ctx, svc, orgUnit)
+ if err != nil {
+ return err
+ }
+ printer.OrgUnitId = orgUnitID
+ }
+
+ created, err := svc.Customers.Chrome.Printers.Create(printerParent(), printer).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create printer %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created printer: %s (%s)\n", created.DisplayName, created.Id)
+ return nil
+}
+
+type PrintersUpdateCmd struct {
+ PrinterID string `arg:"" name:"printer-id" help:"Printer ID"`
+ Name *string `name:"name" help:"Printer name"`
+ URI *string `name:"uri" help:"Printer URI"`
+}
+
+func (c *PrintersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ printerID := strings.TrimSpace(c.PrinterID)
+ if printerID == "" {
+ return usage("printer ID is required")
+ }
+ if c.Name == nil && c.URI == nil {
+ return usage("no updates specified")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ patch := &admin.Printer{}
+ updateMask := make([]string, 0, 2)
+ if c.Name != nil {
+ patch.DisplayName = strings.TrimSpace(*c.Name)
+ updateMask = append(updateMask, "displayName")
+ }
+ if c.URI != nil {
+ patch.Uri = strings.TrimSpace(*c.URI)
+ updateMask = append(updateMask, "uri")
+ }
+
+ updated, err := svc.Customers.Chrome.Printers.Patch(printerResourceName(printerID), patch).
+ UpdateMask(strings.Join(updateMask, ",")).
+ Context(ctx).
+ Do()
+ if err != nil {
+ return fmt.Errorf("update printer %s: %w", printerID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated printer: %s (%s)\n", updated.DisplayName, updated.Id)
+ return nil
+}
+
+type PrintersDeleteCmd struct {
+ PrinterID string `arg:"" name:"printer-id" help:"Printer ID"`
+}
+
+func (c *PrintersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ printerID := strings.TrimSpace(c.PrinterID)
+ if printerID == "" {
+ return usage("printer ID is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete printer %s", printerID)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if _, err := svc.Customers.Chrome.Printers.Delete(printerResourceName(printerID)).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete printer %s: %w", printerID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"printerId": printerID, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted printer: %s\n", printerID)
+ return nil
+}
+
+func printerParent() string {
+ return fmt.Sprintf("customers/%s", adminCustomerID())
+}
+
+func printerResourceName(id string) string {
+ id = strings.TrimSpace(id)
+ if strings.HasPrefix(id, "customers/") {
+ return id
+ }
+ return fmt.Sprintf("customers/%s/chrome/printers/%s", adminCustomerID(), id)
+}
diff --git a/internal/cmd/printers_test.go b/internal/cmd/printers_test.go
new file mode 100644
index 00000000..9e798ab5
--- /dev/null
+++ b/internal/cmd/printers_test.go
@@ -0,0 +1,476 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func TestPrintersListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "printers": []map[string]any{
+ {"id": "p1", "displayName": "HQ Printer", "uri": "ipp://printer"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "HQ Printer") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestPrintersListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "printers": []map[string]any{
+ {"id": "p1", "displayName": "HQ Printer", "uri": "ipp://printer", "orgUnitId": "ou1"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "printers") || !strings.Contains(out, "HQ Printer") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestPrintersListCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "printers": []map[string]any{},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersListCmd{}
+
+ // Should not return error even with empty list
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestPrintersListCmd_Pagination(t *testing.T) {
+ var gotPageToken, gotPageSize string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPageToken = r.URL.Query().Get("pageToken")
+ gotPageSize = r.URL.Query().Get("pageSize")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "printers": []map[string]any{
+ {"id": "p1", "displayName": "Printer 1", "uri": "ipp://p1"},
+ },
+ "nextPageToken": "next-token",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersListCmd{Max: 10, Page: "token123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPageToken != "token123" {
+ t.Errorf("expected page token 'token123', got %q", gotPageToken)
+ }
+ if gotPageSize != "10" {
+ t.Errorf("expected page size '10', got %q", gotPageSize)
+ }
+ if !strings.Contains(out, "Printer 1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestPrintersGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "p1",
+ "displayName": "Office Printer",
+ "uri": "ipp://office",
+ "orgUnitId": "ou1",
+ "description": "Main office printer",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersGetCmd{PrinterID: "p1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Office Printer") || !strings.Contains(out, "ID:") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestPrintersGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "p1",
+ "displayName": "Office Printer",
+ "uri": "ipp://office",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersGetCmd{PrinterID: "p1"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "displayName") || !strings.Contains(out, "Office Printer") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestPrintersGetCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersGetCmd{PrinterID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty printer ID")
+ }
+ if !strings.Contains(err.Error(), "printer ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrintersCreateCmd(t *testing.T) {
+ var gotName string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/chrome/printers") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ DisplayName string `json:"displayName"`
+ Uri string `json:"uri"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotName = payload.DisplayName
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "p2",
+ "displayName": payload.DisplayName,
+ "uri": payload.Uri,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersCreateCmd{Name: "Lab Printer", URI: "ipp://lab"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotName != "Lab Printer" {
+ t.Fatalf("unexpected name: %q", gotName)
+ }
+ if !strings.Contains(out, "Created printer") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestPrintersCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/chrome/printers") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ DisplayName string `json:"displayName"`
+ Uri string `json:"uri"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "p2",
+ "displayName": payload.DisplayName,
+ "uri": payload.Uri,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersCreateCmd{Name: "Lab Printer", URI: "ipp://lab"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "id") || !strings.Contains(out, "p2") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestPrintersCreateCmd_MissingName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersCreateCmd{Name: "", URI: "ipp://test"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing name")
+ }
+ if !strings.Contains(err.Error(), "--name and --uri are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrintersCreateCmd_MissingURI(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersCreateCmd{Name: "Test", URI: ""}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing URI")
+ }
+ if !strings.Contains(err.Error(), "--name and --uri are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrintersUpdateCmd(t *testing.T) {
+ var gotName, gotURI string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/chrome/printers/") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ DisplayName string `json:"displayName"`
+ Uri string `json:"uri"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotName = payload.DisplayName
+ gotURI = payload.Uri
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "p1",
+ "displayName": payload.DisplayName,
+ "uri": payload.Uri,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated Printer"
+ newURI := "ipp://updated"
+ cmd := &PrintersUpdateCmd{PrinterID: "p1", Name: &newName, URI: &newURI}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotName != "Updated Printer" {
+ t.Errorf("unexpected name: %q", gotName)
+ }
+ if gotURI != "ipp://updated" {
+ t.Errorf("unexpected URI: %q", gotURI)
+ }
+ if !strings.Contains(out, "Updated printer") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestPrintersUpdateCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Test"
+ cmd := &PrintersUpdateCmd{PrinterID: " ", Name: &newName}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty printer ID")
+ }
+ if !strings.Contains(err.Error(), "printer ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrintersUpdateCmd_NoUpdates(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &PrintersUpdateCmd{PrinterID: "p1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no updates")
+ }
+ if !strings.Contains(err.Error(), "no updates specified") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrintersDeleteCmd(t *testing.T) {
+ var deletedID string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/chrome/printers/") {
+ http.NotFound(w, r)
+ return
+ }
+ parts := strings.Split(r.URL.Path, "/chrome/printers/")
+ if len(parts) > 1 {
+ deletedID = parts[1]
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &PrintersDeleteCmd{PrinterID: "p1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if deletedID != "p1" {
+ t.Errorf("unexpected deleted ID: %q", deletedID)
+ }
+ if !strings.Contains(out, "Deleted printer") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestPrintersDeleteCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &PrintersDeleteCmd{PrinterID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty printer ID")
+ }
+ if !strings.Contains(err.Error(), "printer ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrintersDeleteCmd_RequiresConfirmation(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", NoInput: true}
+ cmd := &PrintersDeleteCmd{PrinterID: "p1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error when --force not set and non-interactive")
+ }
+ if !strings.Contains(err.Error(), "without --force") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPrinterResourceName(t *testing.T) {
+ tests := []struct {
+ id string
+ expected string
+ }{
+ {"p1", "customers/my_customer/chrome/printers/p1"},
+ {"customers/my_customer/chrome/printers/p2", "customers/my_customer/chrome/printers/p2"},
+ {" p3 ", "customers/my_customer/chrome/printers/p3"},
+ }
+
+ for _, tt := range tests {
+ got := printerResourceName(tt.id)
+ if got != tt.expected {
+ t.Errorf("printerResourceName(%q) = %q, want %q", tt.id, got, tt.expected)
+ }
+ }
+}
+
+func TestPrinterParent(t *testing.T) {
+ expected := "customers/my_customer"
+ got := printerParent()
+ if got != expected {
+ t.Errorf("printerParent() = %q, want %q", got, expected)
+ }
+}
diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go
new file mode 100644
index 00000000..30adb924
--- /dev/null
+++ b/internal/cmd/projects.go
@@ -0,0 +1,215 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/cloudresourcemanager/v3"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newCloudResourceService = googleapi.NewCloudResourceManager
+
+type ProjectsCmd struct {
+ List ProjectsListCmd `cmd:"" name:"list" aliases:"ls" help:"List GCP projects"`
+ Get ProjectsGetCmd `cmd:"" name:"get" help:"Get a GCP project"`
+ Create ProjectsCreateCmd `cmd:"" name:"create" help:"Create a GCP project"`
+ Delete ProjectsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a GCP project"`
+}
+
+type ProjectsListCmd struct {
+ Parent string `name:"parent" help:"Parent resource (organizations/ID or folders/ID)" required:""`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ ShowDeleted bool `name:"show-deleted" help:"Include deleted projects"`
+}
+
+func (c *ProjectsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ parent := strings.TrimSpace(c.Parent)
+ if parent == "" {
+ return usage("--parent is required")
+ }
+
+ svc, err := newCloudResourceService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Projects.List().Parent(parent).PageSize(c.Max).ShowDeleted(c.ShowDeleted)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list projects: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Projects) == 0 {
+ u.Err().Println("No projects found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "PROJECT ID\tNAME\tSTATE")
+ for _, project := range resp.Projects {
+ if project == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(project.ProjectId),
+ sanitizeTab(project.DisplayName),
+ sanitizeTab(project.State),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ProjectsGetCmd struct {
+ Project string `arg:"" name:"project" help:"Project ID or resource name"`
+}
+
+func (c *ProjectsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ project := strings.TrimSpace(c.Project)
+ if project == "" {
+ return usage("project is required")
+ }
+
+ svc, err := newCloudResourceService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ name := normalizeProjectName(project)
+ resp, err := svc.Projects.Get(name).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get project %s: %w", project, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Project ID: %s\n", resp.ProjectId)
+ u.Out().Printf("Name: %s\n", resp.DisplayName)
+ u.Out().Printf("State: %s\n", resp.State)
+ u.Out().Printf("Parent: %s\n", resp.Parent)
+ return nil
+}
+
+type ProjectsCreateCmd struct {
+ ID string `name:"id" help:"Project ID" required:""`
+ Name string `name:"name" help:"Display name" required:""`
+ Parent string `name:"parent" help:"Parent resource (organizations/ID or folders/ID)" required:""`
+}
+
+func (c *ProjectsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ projectID := strings.TrimSpace(c.ID)
+ name := strings.TrimSpace(c.Name)
+ parent := strings.TrimSpace(c.Parent)
+ if projectID == "" || name == "" || parent == "" {
+ return usage("--id, --name, and --parent are required")
+ }
+
+ svc, err := newCloudResourceService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ project := &cloudresourcemanager.Project{
+ ProjectId: projectID,
+ DisplayName: name,
+ Parent: parent,
+ }
+ op, err := svc.Projects.Create(project).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create project: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, op)
+ }
+
+ u.Out().Printf("Requested creation of project %s\n", projectID)
+ if op.Name != "" {
+ u.Out().Printf("Operation: %s\n", op.Name)
+ }
+ return nil
+}
+
+type ProjectsDeleteCmd struct {
+ Project string `arg:"" name:"project" help:"Project ID or resource name"`
+}
+
+func (c *ProjectsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ project := strings.TrimSpace(c.Project)
+ if project == "" {
+ return usage("project is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete project %s", project)); err != nil {
+ return err
+ }
+
+ svc, err := newCloudResourceService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ name := normalizeProjectName(project)
+ op, err := svc.Projects.Delete(name).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("delete project %s: %w", project, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, op)
+ }
+
+ u.Out().Printf("Requested deletion of project %s\n", project)
+ if op.Name != "" {
+ u.Out().Printf("Operation: %s\n", op.Name)
+ }
+ return nil
+}
+
+func normalizeProjectName(project string) string {
+ if strings.HasPrefix(project, "projects/") {
+ return project
+ }
+ return "projects/" + project
+}
diff --git a/internal/cmd/projects_test.go b/internal/cmd/projects_test.go
new file mode 100644
index 00000000..4ad2dd55
--- /dev/null
+++ b/internal/cmd/projects_test.go
@@ -0,0 +1,884 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/cloudresourcemanager/v3"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+func newCloudResourceServiceStub(t *testing.T, handler http.HandlerFunc) (*cloudresourcemanager.Service, func()) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ svc, err := cloudresourcemanager.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ srv.Close()
+ t.Fatalf("NewService: %v", err)
+ }
+ return svc, srv.Close
+}
+
+func stubCloudResourceService(t *testing.T, svc *cloudresourcemanager.Service) {
+ t.Helper()
+ orig := newCloudResourceService
+ t.Cleanup(func() { newCloudResourceService = orig })
+ newCloudResourceService = func(context.Context, string) (*cloudresourcemanager.Service, error) { return svc, nil }
+}
+
+func stubCloudResourceServiceError(t *testing.T, err error) {
+ t.Helper()
+ orig := newCloudResourceService
+ t.Cleanup(func() { newCloudResourceService = orig })
+ newCloudResourceService = func(context.Context, string) (*cloudresourcemanager.Service, error) { return nil, err }
+}
+
+func projectsTestContext(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func projectsTestContextWithStdout(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func projectsTestContextJSON(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+ return outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+}
+
+// -----------------------------------------------------------------------------
+// normalizeProjectName helper tests
+// -----------------------------------------------------------------------------
+
+func TestNormalizeProjectName(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "bare project ID",
+ input: "my-project",
+ want: "projects/my-project",
+ },
+ {
+ name: "already prefixed",
+ input: "projects/my-project",
+ want: "projects/my-project",
+ },
+ {
+ name: "empty string",
+ input: "",
+ want: "projects/",
+ },
+ {
+ name: "project ID with numbers",
+ input: "test-project-123",
+ want: "projects/test-project-123",
+ },
+ {
+ name: "project ID with underscores",
+ input: "my_project_name",
+ want: "projects/my_project_name",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeProjectName(tt.input)
+ if got != tt.want {
+ t.Errorf("normalizeProjectName(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+// -----------------------------------------------------------------------------
+// ProjectsListCmd tests
+// -----------------------------------------------------------------------------
+
+func TestProjectsListCmd(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v3/projects") {
+ http.NotFound(w, r)
+ return
+ }
+ if r.URL.Query().Get("parent") == "" {
+ http.Error(w, "missing parent", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projects": []map[string]any{{
+ "projectId": "p1",
+ "displayName": "Project One",
+ "state": "ACTIVE",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Project One") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestProjectsListCmd_JSON(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v3/projects") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projects": []map[string]any{{
+ "projectId": "p1",
+ "displayName": "Project One",
+ "state": "ACTIVE",
+ }},
+ "nextPageToken": "npt123",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["nextPageToken"] != "npt123" {
+ t.Fatalf("unexpected nextPageToken in JSON: %v", result["nextPageToken"])
+ }
+}
+
+func TestProjectsListCmd_EmptyParent(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: ""}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty parent")
+ }
+ if !strings.Contains(err.Error(), "--parent is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsListCmd_WhitespaceParent(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: " "}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for whitespace-only parent")
+ }
+ if !strings.Contains(err.Error(), "--parent is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsListCmd_NoProjects(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projects": []map[string]any{},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123"}
+
+ // No projects should not return an error, just a message to stderr
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsListCmd_WithPagination(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("pageToken") != "page2" {
+ http.Error(w, "expected page token", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projects": []map[string]any{{
+ "projectId": "p2",
+ "displayName": "Project Two",
+ "state": "ACTIVE",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123", Page: "page2"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Project Two") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestProjectsListCmd_WithShowDeleted(t *testing.T) {
+ var gotShowDeleted bool
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotShowDeleted = r.URL.Query().Get("showDeleted") == "true"
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projects": []map[string]any{{
+ "projectId": "p1",
+ "displayName": "Project One",
+ "state": "DELETE_REQUESTED",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123", ShowDeleted: true}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !gotShowDeleted {
+ t.Fatal("showDeleted was not passed to API")
+ }
+ if !strings.Contains(out, "DELETE_REQUESTED") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestProjectsListCmd_APIError(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "permission denied", http.StatusForbidden)
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+ if !strings.Contains(err.Error(), "list projects") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsListCmd_ServiceError(t *testing.T) {
+ stubCloudResourceServiceError(t, errors.New("service unavailable"))
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for service creation failure")
+ }
+ if !strings.Contains(err.Error(), "service unavailable") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsListCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{Account: ""}
+ cmd := &ProjectsListCmd{Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestProjectsListCmd_FolderParent(t *testing.T) {
+ var gotParent string
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotParent = r.URL.Query().Get("parent")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projects": []map[string]any{{
+ "projectId": "p1",
+ "displayName": "Project One",
+ "state": "ACTIVE",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsListCmd{Parent: "folders/456"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotParent != "folders/456" {
+ t.Fatalf("unexpected parent: %s", gotParent)
+ }
+}
+
+// -----------------------------------------------------------------------------
+// ProjectsGetCmd tests
+// -----------------------------------------------------------------------------
+
+func TestProjectsGetCmd(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/v3/projects/my-project" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "projects/my-project",
+ "projectId": "my-project",
+ "displayName": "My Project",
+ "state": "ACTIVE",
+ "parent": "organizations/123",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: "my-project"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "my-project") {
+ t.Fatalf("expected project ID in output: %s", out)
+ }
+ if !strings.Contains(out, "My Project") {
+ t.Fatalf("expected display name in output: %s", out)
+ }
+ if !strings.Contains(out, "ACTIVE") {
+ t.Fatalf("expected state in output: %s", out)
+ }
+ if !strings.Contains(out, "organizations/123") {
+ t.Fatalf("expected parent in output: %s", out)
+ }
+}
+
+func TestProjectsGetCmd_WithPrefix(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v3/projects/my-project" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "projectId": "my-project",
+ "displayName": "My Project",
+ "state": "ACTIVE",
+ "parent": "organizations/123",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: "projects/my-project"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "my-project") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestProjectsGetCmd_JSON(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "projects/my-project",
+ "projectId": "my-project",
+ "displayName": "My Project",
+ "state": "ACTIVE",
+ "parent": "organizations/123",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: "my-project"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["projectId"] != "my-project" {
+ t.Fatalf("unexpected projectId in JSON: %v", result["projectId"])
+ }
+ if result["state"] != "ACTIVE" {
+ t.Fatalf("unexpected state in JSON: %v", result["state"])
+ }
+}
+
+func TestProjectsGetCmd_EmptyProject(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: ""}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty project")
+ }
+ if !strings.Contains(err.Error(), "project is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsGetCmd_WhitespaceProject(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: " "}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for whitespace-only project")
+ }
+ if !strings.Contains(err.Error(), "project is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsGetCmd_NotFound(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "project not found", http.StatusNotFound)
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: "nonexistent"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for not found project")
+ }
+ if !strings.Contains(err.Error(), "get project") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsGetCmd_ServiceError(t *testing.T) {
+ stubCloudResourceServiceError(t, errors.New("service unavailable"))
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsGetCmd{Project: "my-project"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for service creation failure")
+ }
+ if !strings.Contains(err.Error(), "service unavailable") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsGetCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{Account: ""}
+ cmd := &ProjectsGetCmd{Project: "my-project"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+// -----------------------------------------------------------------------------
+// ProjectsCreateCmd tests
+// -----------------------------------------------------------------------------
+
+func TestProjectsCreateCmd(t *testing.T) {
+ var gotID string
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/v3/projects") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload cloudresourcemanager.Project
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotID = payload.ProjectId
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotID != "p1" {
+ t.Fatalf("unexpected project id: %s", gotID)
+ }
+ if !strings.Contains(out, "Requested creation") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestProjectsCreateCmd_JSON(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1", "done": false})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["name"] != "operations/op1" {
+ t.Fatalf("unexpected operation name in JSON: %v", result["name"])
+ }
+}
+
+func TestProjectsCreateCmd_Payload(t *testing.T) {
+ var gotPayload cloudresourcemanager.Project
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewDecoder(r.Body).Decode(&gotPayload)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "test-project-id", Name: "Test Project Name", Parent: "folders/789"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPayload.ProjectId != "test-project-id" {
+ t.Fatalf("unexpected project ID in payload: %s", gotPayload.ProjectId)
+ }
+ if gotPayload.DisplayName != "Test Project Name" {
+ t.Fatalf("unexpected display name in payload: %s", gotPayload.DisplayName)
+ }
+ if gotPayload.Parent != "folders/789" {
+ t.Fatalf("unexpected parent in payload: %s", gotPayload.Parent)
+ }
+}
+
+func TestProjectsCreateCmd_MissingID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "", Name: "Project One", Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing ID")
+ }
+ if !strings.Contains(err.Error(), "--id") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsCreateCmd_MissingName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "p1", Name: "", Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing name")
+ }
+ if !strings.Contains(err.Error(), "--name") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsCreateCmd_MissingParent(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: ""}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing parent")
+ }
+ if !strings.Contains(err.Error(), "--parent") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsCreateCmd_APIError(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "quota exceeded", http.StatusTooManyRequests)
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+ if !strings.Contains(err.Error(), "create project") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsCreateCmd_ServiceError(t *testing.T) {
+ stubCloudResourceServiceError(t, errors.New("service unavailable"))
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for service creation failure")
+ }
+}
+
+// -----------------------------------------------------------------------------
+// ProjectsDeleteCmd tests
+// -----------------------------------------------------------------------------
+
+func TestProjectsDeleteCmd(t *testing.T) {
+ var deleteCalled bool
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || r.URL.Path != "/v3/projects/my-project" {
+ http.NotFound(w, r)
+ return
+ }
+ deleteCalled = true
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/delete-op"})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: "my-project"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !deleteCalled {
+ t.Fatal("delete was not called")
+ }
+ if !strings.Contains(out, "Requested deletion") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestProjectsDeleteCmd_WithPrefix(t *testing.T) {
+ var gotPath string
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/delete-op"})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: "projects/my-project"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPath != "/v3/projects/my-project" {
+ t.Fatalf("unexpected path: %s", gotPath)
+ }
+}
+
+func TestProjectsDeleteCmd_JSON(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/delete-op", "done": false})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: "my-project"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["name"] != "operations/delete-op" {
+ t.Fatalf("unexpected operation name in JSON: %v", result["name"])
+ }
+}
+
+func TestProjectsDeleteCmd_EmptyProject(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: ""}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty project")
+ }
+ if !strings.Contains(err.Error(), "project is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsDeleteCmd_APIError(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "permission denied", http.StatusForbidden)
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: "my-project"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+ if !strings.Contains(err.Error(), "delete project") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestProjectsDeleteCmd_ServiceError(t *testing.T) {
+ stubCloudResourceServiceError(t, errors.New("service unavailable"))
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: "my-project"}
+
+ err := cmd.Run(projectsTestContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for service creation failure")
+ }
+}
+
+func TestProjectsDeleteCmd_WithOperationName(t *testing.T) {
+ svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/long-running-delete"})
+ }))
+ t.Cleanup(closeSrv)
+ stubCloudResourceService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ProjectsDeleteCmd{Project: "my-project"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Operation:") {
+ t.Fatalf("expected operation name in output: %s", out)
+ }
+ if !strings.Contains(out, "operations/long-running-delete") {
+ t.Fatalf("expected full operation name in output: %s", out)
+ }
+}
diff --git a/internal/cmd/reports.go b/internal/cmd/reports.go
new file mode 100644
index 00000000..2050a362
--- /dev/null
+++ b/internal/cmd/reports.go
@@ -0,0 +1,409 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+ reports "google.golang.org/api/admin/reports/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newReportsService = googleapi.NewReports
+
+type ReportsCmd struct {
+ User ReportsUserCmd `cmd:"" name:"user" help:"User activity reports"`
+ Admin ReportsAdminCmd `cmd:"" name:"admin" help:"Admin activity reports"`
+ Login ReportsLoginCmd `cmd:"" name:"login" help:"Login activity reports"`
+ Drive ReportsDriveCmd `cmd:"" name:"drive" help:"Drive activity reports"`
+ Usage ReportsUsageCmd `cmd:"" name:"usage" help:"Customer usage reports"`
+ Accounts ReportsAccountsCmd `cmd:"" name:"accounts" help:"Account usage reports"`
+ EmailLog ReportsEmailLogCmd `cmd:"" name:"email-log" help:"Email log search"`
+}
+
+type ReportsUserCmd struct {
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ User string `name:"user" help:"User email or ID (default: all)"`
+ Filters string `name:"filters" help:"Filters query"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ ToDrive ToDriveFlags `embed:""`
+}
+
+func (c *ReportsUserCmd) Run(ctx context.Context, flags *RootFlags) error {
+ return runActivityReport(ctx, flags, activityReportOptions{
+ Application: "user",
+ Date: c.Date,
+ User: c.User,
+ Filters: c.Filters,
+ Max: c.Max,
+ Page: c.Page,
+ ToDrive: c.ToDrive,
+ })
+}
+
+type ReportsAdminCmd struct {
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ Event string `name:"event" help:"Event name filter"`
+ Filters string `name:"filters" help:"Filters query"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ ToDrive ToDriveFlags `embed:""`
+}
+
+func (c *ReportsAdminCmd) Run(ctx context.Context, flags *RootFlags) error {
+ return runActivityReport(ctx, flags, activityReportOptions{
+ Application: "admin",
+ Date: c.Date,
+ Event: c.Event,
+ Filters: c.Filters,
+ Max: c.Max,
+ Page: c.Page,
+ ToDrive: c.ToDrive,
+ })
+}
+
+type ReportsLoginCmd struct {
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ User string `name:"user" help:"User email or ID (default: all)"`
+ Filters string `name:"filters" help:"Filters query"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ ToDrive ToDriveFlags `embed:""`
+}
+
+func (c *ReportsLoginCmd) Run(ctx context.Context, flags *RootFlags) error {
+ return runActivityReport(ctx, flags, activityReportOptions{
+ Application: "login",
+ Date: c.Date,
+ User: c.User,
+ Filters: c.Filters,
+ Max: c.Max,
+ Page: c.Page,
+ ToDrive: c.ToDrive,
+ })
+}
+
+type ReportsDriveCmd struct {
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ User string `name:"user" help:"User email or ID (default: all)"`
+ Filters string `name:"filters" help:"Filters query"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ ToDrive ToDriveFlags `embed:""`
+}
+
+func (c *ReportsDriveCmd) Run(ctx context.Context, flags *RootFlags) error {
+ return runActivityReport(ctx, flags, activityReportOptions{
+ Application: "drive",
+ Date: c.Date,
+ User: c.User,
+ Filters: c.Filters,
+ Max: c.Max,
+ Page: c.Page,
+ ToDrive: c.ToDrive,
+ })
+}
+
+type ReportsUsageCmd struct {
+ Application string `arg:"" name:"application" help:"Application name"`
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ Parameters string `name:"parameters" help:"Comma-separated parameters"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ReportsUsageCmd) Run(ctx context.Context, flags *RootFlags) error {
+ if strings.TrimSpace(c.Application) == "" {
+ return usage("application required")
+ }
+ return runUsageReport(ctx, flags, c.Application, c.Date, c.Parameters, c.Page)
+}
+
+type ReportsAccountsCmd struct {
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ReportsAccountsCmd) Run(ctx context.Context, flags *RootFlags) error {
+ return runUsageReport(ctx, flags, "accounts", c.Date, "", c.Page)
+}
+
+type ReportsEmailLogCmd struct {
+ Date string `name:"date" help:"Report date (YYYY-MM-DD)"`
+ Recipient string `name:"recipient" help:"Recipient email filter"`
+ Filters string `name:"filters" help:"Filters query"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ReportsEmailLogCmd) Run(ctx context.Context, flags *RootFlags) error {
+ filters := strings.TrimSpace(c.Filters)
+ if c.Recipient != "" {
+ recipientFilter := fmt.Sprintf("recipient==%s", c.Recipient)
+ if filters == "" {
+ filters = recipientFilter
+ } else {
+ filters = filters + "," + recipientFilter
+ }
+ }
+ return runActivityReport(ctx, flags, activityReportOptions{
+ Application: "email",
+ Date: c.Date,
+ User: "all",
+ Filters: filters,
+ Max: c.Max,
+ Page: c.Page,
+ })
+}
+
+type activityReportOptions struct {
+ Application string
+ Date string
+ User string
+ Event string
+ Filters string
+ Max int64
+ Page string
+ ToDrive ToDriveFlags
+}
+
+func runActivityReport(ctx context.Context, flags *RootFlags, opts activityReportOptions) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newReportsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ userKey := strings.TrimSpace(opts.User)
+ if userKey == "" {
+ userKey = "all"
+ }
+
+ call := svc.Activities.List(userKey, opts.Application)
+ if date := reportDate(opts.Date); date != "" {
+ start, end := reportDateRange(date)
+ call = call.StartTime(start).EndTime(end)
+ }
+ if opts.Event != "" {
+ call = call.EventName(opts.Event)
+ }
+ if opts.Filters != "" {
+ call = call.Filters(opts.Filters)
+ }
+ if opts.Max > 0 {
+ call = call.MaxResults(opts.Max)
+ }
+ if opts.Page != "" {
+ call = call.PageToken(opts.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("fetch %s report: %w", opts.Application, err)
+ }
+
+ if len(resp.Items) == 0 {
+ u.Err().Println("No events found")
+ return nil
+ }
+
+ rows := make([][]string, 0, len(resp.Items))
+ for _, item := range resp.Items {
+ if item == nil {
+ continue
+ }
+ timeStr := formatActivityTime(item.Id)
+ actor := ""
+ if item.Actor != nil {
+ actor = item.Actor.Email
+ }
+ events := activityEventNames(item.Events)
+ rows = append(rows, toDriveRow(
+ timeStr,
+ actor,
+ item.IpAddress,
+ events,
+ ))
+ }
+
+ reportTitle := fmt.Sprintf("Reports %s", cases.Title(language.English).String(opts.Application))
+ if ok, err := writeToDrive(ctx, flags, toDriveTitle(reportTitle, opts.ToDrive), []string{"TIME", "ACTOR", "IP", "EVENTS"}, rows, opts.ToDrive); ok {
+ return err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "TIME\tACTOR\tIP\tEVENTS")
+ for _, row := range rows {
+ if len(row) < 4 {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(row[0]),
+ sanitizeTab(row[1]),
+ sanitizeTab(row[2]),
+ sanitizeTab(row[3]),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+func runUsageReport(ctx context.Context, flags *RootFlags, application, date, parameters, page string) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newReportsService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ date = reportDate(date)
+
+ call := svc.CustomerUsageReports.Get(date).CustomerId(adminCustomerID())
+ params := strings.TrimSpace(parameters)
+ if params == "" {
+ params = application
+ } else if !strings.Contains(params, ":") {
+ params = fmt.Sprintf("%s:%s", application, params)
+ }
+ if params != "" {
+ call = call.Parameters(params)
+ }
+ if page != "" {
+ call = call.PageToken(page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("fetch usage report: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.UsageReports) == 0 {
+ u.Err().Println("No usage reports found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "DATE\tENTITY\tPARAMETERS")
+ for _, report := range resp.UsageReports {
+ if report == nil {
+ continue
+ }
+ entity := ""
+ if report.Entity != nil {
+ entity = report.Entity.Type
+ if report.Entity.EntityId != "" {
+ entity = entity + ":" + report.Entity.EntityId
+ }
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(report.Date),
+ sanitizeTab(entity),
+ sanitizeTab(formatUsageParameters(report.Parameters)),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+func reportDate(date string) string {
+ date = strings.TrimSpace(date)
+ if date != "" {
+ return date
+ }
+ return time.Now().UTC().Format("2006-01-02")
+}
+
+func reportDateRange(date string) (string, string) {
+ t, err := time.Parse("2006-01-02", date)
+ if err != nil {
+ return date, date
+ }
+ start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+ end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC)
+ return start.Format(time.RFC3339), end.Format(time.RFC3339)
+}
+
+func formatActivityTime(id *reports.ActivityId) string {
+ if id == nil || id.Time == "" {
+ return ""
+ }
+ sec, err := strconv.ParseInt(id.Time, 10, 64)
+ if err != nil {
+ return id.Time
+ }
+ return time.Unix(sec, 0).UTC().Format(time.RFC3339)
+}
+
+func activityEventNames(events []*reports.ActivityEvents) string {
+ if len(events) == 0 {
+ return ""
+ }
+ out := make([]string, 0, len(events))
+ for _, ev := range events {
+ if ev == nil || ev.Name == "" {
+ continue
+ }
+ out = append(out, ev.Name)
+ }
+ return strings.Join(out, ",")
+}
+
+func formatUsageParameters(params []*reports.UsageReportParameters) string {
+ if len(params) == 0 {
+ return ""
+ }
+ out := make([]string, 0, len(params))
+ for _, p := range params {
+ if p == nil {
+ continue
+ }
+ value := ""
+ switch {
+ case p.StringValue != "":
+ value = p.StringValue
+ case p.DatetimeValue != "":
+ value = p.DatetimeValue
+ default:
+ if p.IntValue != 0 {
+ value = strconv.FormatInt(p.IntValue, 10)
+ } else if p.BoolValue {
+ value = strconv.FormatBool(p.BoolValue)
+ }
+ }
+ if p.Name != "" {
+ if value != "" {
+ out = append(out, fmt.Sprintf("%s=%s", p.Name, value))
+ } else {
+ out = append(out, p.Name)
+ }
+ }
+ }
+ return strings.Join(out, ",")
+}
diff --git a/internal/cmd/reports_test.go b/internal/cmd/reports_test.go
new file mode 100644
index 00000000..e6669450
--- /dev/null
+++ b/internal/cmd/reports_test.go
@@ -0,0 +1,1649 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ reports "google.golang.org/api/admin/reports/v1"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func TestReportsUserCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "sam@example.com"},
+ "ipAddress": "1.2.3.4",
+ "events": []map[string]any{
+ {"name": "login"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "sam@example.com") || !strings.Contains(out, "login") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUserCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "json@example.com"},
+ "ipAddress": "5.6.7.8",
+ "events": []map[string]any{
+ {"name": "user_event"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "json@example.com") || !strings.Contains(out, "items") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsUserCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ // No error expected, just "no events" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsUserCmd_WithFilters(t *testing.T) {
+ var gotFilters string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotFilters = r.URL.Query().Get("filters")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "filtered@example.com"},
+ "ipAddress": "1.1.1.1",
+ "events": []map[string]any{
+ {"name": "filtered_event"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02", Filters: "event_name==login"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotFilters != "event_name==login" {
+ t.Fatalf("expected filters event_name==login, got %q", gotFilters)
+ }
+ if !strings.Contains(out, "filtered@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsAdminCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Verify application is admin
+ if !strings.Contains(r.URL.Path, "/admin") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "admin@example.com"},
+ "ipAddress": "10.0.0.1",
+ "events": []map[string]any{
+ {"name": "CREATE_USER"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAdminCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "admin@example.com") || !strings.Contains(out, "CREATE_USER") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsAdminCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "admin-json@example.com"},
+ "ipAddress": "10.0.0.2",
+ "events": []map[string]any{
+ {"name": "DELETE_USER"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAdminCmd{Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "admin-json@example.com") || !strings.Contains(out, "items") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsAdminCmd_WithEvent(t *testing.T) {
+ var gotEventName string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotEventName = r.URL.Query().Get("eventName")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "admin@example.com"},
+ "ipAddress": "10.0.0.1",
+ "events": []map[string]any{
+ {"name": "CREATE_USER"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAdminCmd{Date: "2026-01-02", Event: "CREATE_USER"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotEventName != "CREATE_USER" {
+ t.Fatalf("expected event name CREATE_USER, got %q", gotEventName)
+ }
+ if !strings.Contains(out, "CREATE_USER") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsAdminCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAdminCmd{Date: "2026-01-02"}
+
+ // No error expected, just "no events" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsLoginCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Verify application is login
+ if !strings.Contains(r.URL.Path, "/login") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "user@example.com"},
+ "ipAddress": "192.168.1.1",
+ "events": []map[string]any{
+ {"name": "login_success"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsLoginCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "user@example.com") || !strings.Contains(out, "login_success") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsLoginCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "login-json@example.com"},
+ "ipAddress": "192.168.1.2",
+ "events": []map[string]any{
+ {"name": "login_failure"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsLoginCmd{Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "login-json@example.com") || !strings.Contains(out, "items") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsLoginCmd_WithUser(t *testing.T) {
+ var gotUserKey string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Extract user key from path
+ parts := strings.Split(r.URL.Path, "/")
+ for i, part := range parts {
+ if part == "users" && i+1 < len(parts) {
+ gotUserKey = parts[i+1]
+ break
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "specific@example.com"},
+ "ipAddress": "192.168.1.3",
+ "events": []map[string]any{
+ {"name": "login_success"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsLoginCmd{Date: "2026-01-02", User: "specific@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotUserKey != "specific@example.com" {
+ t.Fatalf("expected user key specific@example.com, got %q", gotUserKey)
+ }
+ if !strings.Contains(out, "specific@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsLoginCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsLoginCmd{Date: "2026-01-02"}
+
+ // No error expected, just "no events" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsDriveCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Verify application is drive
+ if !strings.Contains(r.URL.Path, "/drive") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "drive@example.com"},
+ "ipAddress": "172.16.0.1",
+ "events": []map[string]any{
+ {"name": "view"},
+ {"name": "download"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsDriveCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "drive@example.com") || !strings.Contains(out, "view") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsDriveCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "drive-json@example.com"},
+ "ipAddress": "172.16.0.2",
+ "events": []map[string]any{
+ {"name": "edit"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsDriveCmd{Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "drive-json@example.com") || !strings.Contains(out, "items") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsDriveCmd_WithUser(t *testing.T) {
+ var gotUserKey string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Extract user key from path
+ parts := strings.Split(r.URL.Path, "/")
+ for i, part := range parts {
+ if part == "users" && i+1 < len(parts) {
+ gotUserKey = parts[i+1]
+ break
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "driveuser@example.com"},
+ "ipAddress": "172.16.0.3",
+ "events": []map[string]any{
+ {"name": "create"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsDriveCmd{Date: "2026-01-02", User: "driveuser@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotUserKey != "driveuser@example.com" {
+ t.Fatalf("expected user key driveuser@example.com, got %q", gotUserKey)
+ }
+ if !strings.Contains(out, "driveuser@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsDriveCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsDriveCmd{Date: "2026-01-02"}
+
+ // No error expected, just "no events" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsUsageCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ "customerId": "my_customer",
+ },
+ "parameters": []map[string]any{
+ {"name": "num_users", "intValue": "42"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02", Parameters: "num_users"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "num_users=42") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ },
+ "parameters": []map[string]any{
+ {"name": "storage_used", "intValue": "1024"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "drive", Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "usageReports") || !strings.Contains(out, "storage_used") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_RequiresApplication(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "", Date: "2026-01-02"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error when Application is empty")
+ }
+}
+
+func TestReportsUsageCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"}
+
+ // No error expected, just "no reports" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsAccountsCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "USER",
+ "entityId": "user123",
+ },
+ "parameters": []map[string]any{
+ {"name": "accounts:total_accounts", "intValue": "100"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAccountsCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "2026-01-02") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsAccountsCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "USER",
+ },
+ "parameters": []map[string]any{
+ {"name": "accounts:active_accounts", "intValue": "85"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAccountsCmd{Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "usageReports") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsAccountsCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsAccountsCmd{Date: "2026-01-02"}
+
+ // No error expected, just "no reports" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsEmailLogCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ // Verify application is email
+ if !strings.Contains(r.URL.Path, "/email") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "sender@example.com"},
+ "ipAddress": "203.0.113.1",
+ "events": []map[string]any{
+ {"name": "email_sent"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsEmailLogCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "sender@example.com") || !strings.Contains(out, "email_sent") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsEmailLogCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "email-json@example.com"},
+ "ipAddress": "203.0.113.2",
+ "events": []map[string]any{
+ {"name": "email_received"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsEmailLogCmd{Date: "2026-01-02"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "email-json@example.com") || !strings.Contains(out, "items") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestReportsEmailLogCmd_WithRecipient(t *testing.T) {
+ var gotFilters string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotFilters = r.URL.Query().Get("filters")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "sender@example.com"},
+ "ipAddress": "203.0.113.3",
+ "events": []map[string]any{
+ {"name": "email_sent"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsEmailLogCmd{Date: "2026-01-02", Recipient: "recipient@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(gotFilters, "recipient==recipient@example.com") {
+ t.Fatalf("expected recipient filter, got %q", gotFilters)
+ }
+ if !strings.Contains(out, "sender@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsEmailLogCmd_WithFiltersAndRecipient(t *testing.T) {
+ var gotFilters string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotFilters = r.URL.Query().Get("filters")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "sender@example.com"},
+ "ipAddress": "203.0.113.4",
+ "events": []map[string]any{
+ {"name": "email_sent"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsEmailLogCmd{
+ Date: "2026-01-02",
+ Recipient: "recipient@example.com",
+ Filters: "message_size>1000",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should contain both filters combined
+ if !strings.Contains(gotFilters, "message_size>1000") {
+ t.Fatalf("expected message_size filter, got %q", gotFilters)
+ }
+ if !strings.Contains(gotFilters, "recipient==recipient@example.com") {
+ t.Fatalf("expected recipient filter, got %q", gotFilters)
+ }
+ if !strings.Contains(out, "sender@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsEmailLogCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsEmailLogCmd{Date: "2026-01-02"}
+
+ // No error expected, just "no events" message
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestReportsActivityCmd_Pagination(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "page@example.com"},
+ "ipAddress": "1.1.1.1",
+ "events": []map[string]any{
+ {"name": "event1"},
+ },
+ },
+ },
+ "nextPageToken": "next_page_token_123",
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "page@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsActivityCmd_MaxResults(t *testing.T) {
+ var gotMaxResults string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotMaxResults = r.URL.Query().Get("maxResults")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "max@example.com"},
+ "ipAddress": "1.1.1.1",
+ "events": []map[string]any{
+ {"name": "event1"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02", Max: 50}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotMaxResults != "50" {
+ t.Fatalf("expected maxResults 50, got %q", gotMaxResults)
+ }
+ if !strings.Contains(out, "max@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsActivityCmd_PageToken(t *testing.T) {
+ var gotPageToken string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPageToken = r.URL.Query().Get("pageToken")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "paged@example.com"},
+ "ipAddress": "1.1.1.1",
+ "events": []map[string]any{
+ {"name": "event1"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02", Page: "my_page_token"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPageToken != "my_page_token" {
+ t.Fatalf("expected pageToken my_page_token, got %q", gotPageToken)
+ }
+ if !strings.Contains(out, "paged@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsActivityCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 500,
+ "message": "Internal server error",
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "fetch user report") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestReportsUsageCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 403,
+ "message": "Access denied",
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "fetch usage report") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestReportsActivityCmd_NilActor(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "ipAddress": "1.1.1.1",
+ "events": []map[string]any{
+ {"name": "system_event"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "system_event") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsActivityCmd_MultipleEvents(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "id": map[string]any{"time": "1700000000"},
+ "actor": map[string]any{"email": "multi@example.com"},
+ "ipAddress": "1.1.1.1",
+ "events": []map[string]any{
+ {"name": "event1"},
+ {"name": "event2"},
+ {"name": "event3"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Events should be comma-separated
+ if !strings.Contains(out, "event1,event2,event3") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_WithParameters(t *testing.T) {
+ var gotParams string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotParams = r.URL.Query().Get("parameters")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ },
+ "parameters": []map[string]any{
+ {"name": "accounts:num_users", "intValue": "100"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "accounts", Date: "2026-01-02", Parameters: "num_users"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Parameters should be prefixed with application
+ if gotParams != "accounts:num_users" {
+ t.Fatalf("expected parameters accounts:num_users, got %q", gotParams)
+ }
+ if !strings.Contains(out, "num_users=100") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_FullyQualifiedParameters(t *testing.T) {
+ var gotParams string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ gotParams = r.URL.Query().Get("parameters")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ },
+ "parameters": []map[string]any{
+ {"name": "drive:total_storage", "intValue": "5000"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "accounts", Date: "2026-01-02", Parameters: "drive:total_storage"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Fully qualified parameters should not be prefixed again
+ if gotParams != "drive:total_storage" {
+ t.Fatalf("expected parameters drive:total_storage, got %q", gotParams)
+ }
+ if !strings.Contains(out, "total_storage=5000") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_StringParameter(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ },
+ "parameters": []map[string]any{
+ {"name": "version", "stringValue": "v2.0"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "version=v2.0") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_DatetimeParameter(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ },
+ "parameters": []map[string]any{
+ {"name": "last_sync", "datetimeValue": "2026-01-02T10:00:00Z"},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "last_sync=2026-01-02T10:00:00Z") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportsUsageCmd_BoolParameter(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "usageReports": []map[string]any{
+ {
+ "date": "2026-01-02",
+ "entity": map[string]any{
+ "type": "CUSTOMER",
+ },
+ "parameters": []map[string]any{
+ {"name": "enabled", "boolValue": true},
+ },
+ },
+ },
+ })
+ })
+ stubReports(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "enabled=true") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestReportDate(t *testing.T) {
+ // Test with empty date - should return today's date
+ got := reportDate("")
+ if got == "" {
+ t.Fatal("expected non-empty date")
+ }
+
+ // Test with explicit date
+ got = reportDate("2026-05-15")
+ if got != "2026-05-15" {
+ t.Fatalf("expected 2026-05-15, got %s", got)
+ }
+
+ // Test with whitespace
+ got = reportDate(" 2026-05-15 ")
+ if got != "2026-05-15" {
+ t.Fatalf("expected 2026-05-15, got %s", got)
+ }
+}
+
+func TestReportDateRange(t *testing.T) {
+ start, end := reportDateRange("2026-05-15")
+ if start != "2026-05-15T00:00:00Z" {
+ t.Fatalf("expected start 2026-05-15T00:00:00Z, got %s", start)
+ }
+ if end != "2026-05-15T23:59:59Z" {
+ t.Fatalf("expected end 2026-05-15T23:59:59Z, got %s", end)
+ }
+
+ // Test with invalid date
+ start, end = reportDateRange("invalid")
+ if start != "invalid" || end != "invalid" {
+ t.Fatalf("expected invalid for both, got start=%s end=%s", start, end)
+ }
+}
+
+func TestFormatActivityTime(t *testing.T) {
+ // Test with nil
+ got := formatActivityTime(nil)
+ if got != "" {
+ t.Fatalf("expected empty string, got %s", got)
+ }
+
+ // Test with empty time
+ got = formatActivityTime(&reports.ActivityId{Time: ""})
+ if got != "" {
+ t.Fatalf("expected empty string, got %s", got)
+ }
+
+ // Test with valid unix timestamp
+ got = formatActivityTime(&reports.ActivityId{Time: "1700000000"})
+ if got == "" || got == "1700000000" {
+ t.Fatalf("expected formatted time, got %s", got)
+ }
+
+ // Test with invalid timestamp
+ got = formatActivityTime(&reports.ActivityId{Time: "not_a_number"})
+ if got != "not_a_number" {
+ t.Fatalf("expected original value, got %s", got)
+ }
+}
+
+func TestActivityEventNames(t *testing.T) {
+ // Test with nil
+ got := activityEventNames(nil)
+ if got != "" {
+ t.Fatalf("expected empty string, got %s", got)
+ }
+
+ // Test with empty slice
+ got = activityEventNames([]*reports.ActivityEvents{})
+ if got != "" {
+ t.Fatalf("expected empty string, got %s", got)
+ }
+
+ // Test with single event
+ got = activityEventNames([]*reports.ActivityEvents{
+ {Name: "event1"},
+ })
+ if got != "event1" {
+ t.Fatalf("expected event1, got %s", got)
+ }
+
+ // Test with multiple events
+ got = activityEventNames([]*reports.ActivityEvents{
+ {Name: "event1"},
+ {Name: "event2"},
+ {Name: "event3"},
+ })
+ if got != "event1,event2,event3" {
+ t.Fatalf("expected event1,event2,event3, got %s", got)
+ }
+
+ // Test with nil event in slice
+ got = activityEventNames([]*reports.ActivityEvents{
+ {Name: "event1"},
+ nil,
+ {Name: "event3"},
+ })
+ if got != "event1,event3" {
+ t.Fatalf("expected event1,event3, got %s", got)
+ }
+
+ // Test with empty name
+ got = activityEventNames([]*reports.ActivityEvents{
+ {Name: "event1"},
+ {Name: ""},
+ {Name: "event3"},
+ })
+ if got != "event1,event3" {
+ t.Fatalf("expected event1,event3, got %s", got)
+ }
+}
+
+func TestFormatUsageParameters(t *testing.T) {
+ // Test with nil
+ got := formatUsageParameters(nil)
+ if got != "" {
+ t.Fatalf("expected empty string, got %s", got)
+ }
+
+ // Test with empty slice
+ got = formatUsageParameters([]*reports.UsageReportParameters{})
+ if got != "" {
+ t.Fatalf("expected empty string, got %s", got)
+ }
+
+ // Test with string value
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "version", StringValue: "v1.0"},
+ })
+ if got != "version=v1.0" {
+ t.Fatalf("expected version=v1.0, got %s", got)
+ }
+
+ // Test with int value
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "count", IntValue: 42},
+ })
+ if got != "count=42" {
+ t.Fatalf("expected count=42, got %s", got)
+ }
+
+ // Test with bool value
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "enabled", BoolValue: true},
+ })
+ if got != "enabled=true" {
+ t.Fatalf("expected enabled=true, got %s", got)
+ }
+
+ // Test with datetime value
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "timestamp", DatetimeValue: "2026-01-01T00:00:00Z"},
+ })
+ if got != "timestamp=2026-01-01T00:00:00Z" {
+ t.Fatalf("expected timestamp=2026-01-01T00:00:00Z, got %s", got)
+ }
+
+ // Test with multiple parameters
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "param1", StringValue: "value1"},
+ {Name: "param2", IntValue: 100},
+ })
+ if got != "param1=value1,param2=100" {
+ t.Fatalf("expected param1=value1,param2=100, got %s", got)
+ }
+
+ // Test with nil parameter in slice
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "param1", StringValue: "value1"},
+ nil,
+ {Name: "param3", IntValue: 300},
+ })
+ if got != "param1=value1,param3=300" {
+ t.Fatalf("expected param1=value1,param3=300, got %s", got)
+ }
+
+ // Test with name only (no value)
+ got = formatUsageParameters([]*reports.UsageReportParameters{
+ {Name: "feature_enabled"},
+ })
+ if got != "feature_enabled" {
+ t.Fatalf("expected feature_enabled, got %s", got)
+ }
+}
+
+func stubReports(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newReportsService
+ svc, err := reports.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new reports service: %v", err)
+ }
+ newReportsService = func(context.Context, string) (*reports.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newReportsService = orig
+ srv.Close()
+ })
+}
diff --git a/internal/cmd/reseller.go b/internal/cmd/reseller.go
new file mode 100644
index 00000000..8d3a316d
--- /dev/null
+++ b/internal/cmd/reseller.go
@@ -0,0 +1,379 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "google.golang.org/api/reseller/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newResellerService = googleapi.NewReseller
+
+type ResellerCmd struct {
+ Customers ResellerCustomersCmd `cmd:"" name:"customers" help:"Reseller customers"`
+ Subscriptions ResellerSubscriptionsCmd `cmd:"" name:"subscriptions" help:"Reseller subscriptions"`
+}
+
+type ResellerCustomersCmd struct {
+ List ResellerCustomersListCmd `cmd:"" name:"list" aliases:"ls" help:"List reseller customers"`
+ Get ResellerCustomersGetCmd `cmd:"" name:"get" help:"Get reseller customer"`
+ Create ResellerCustomersCreateCmd `cmd:"" name:"create" help:"Create reseller customer"`
+}
+
+type ResellerCustomersListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ Prefix string `name:"prefix" help:"Customer name prefix filter"`
+}
+
+func (c *ResellerCustomersListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newResellerService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Subscriptions.List().MaxResults(c.Max)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ if strings.TrimSpace(c.Prefix) != "" {
+ call = call.CustomerNamePrefix(strings.TrimSpace(c.Prefix))
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list customers: %w", err)
+ }
+
+ type customer struct {
+ ID string `json:"id"`
+ Domain string `json:"domain"`
+ }
+ seen := make(map[string]customer)
+ for _, sub := range resp.Subscriptions {
+ if sub == nil || sub.CustomerId == "" {
+ continue
+ }
+ if _, ok := seen[sub.CustomerId]; ok {
+ continue
+ }
+ seen[sub.CustomerId] = customer{ID: sub.CustomerId, Domain: sub.CustomerDomain}
+ }
+ customers := make([]customer, 0, len(seen))
+ for _, cust := range seen {
+ customers = append(customers, cust)
+ }
+ sort.Slice(customers, func(i, j int) bool {
+ if customers[i].Domain == customers[j].Domain {
+ return customers[i].ID < customers[j].ID
+ }
+ return customers[i].Domain < customers[j].Domain
+ })
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{
+ "customers": customers,
+ "nextPageToken": resp.NextPageToken,
+ })
+ }
+
+ if len(customers) == 0 {
+ u.Err().Println("No customers found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "CUSTOMER ID\tDOMAIN")
+ for _, cust := range customers {
+ fmt.Fprintf(w, "%s\t%s\n", sanitizeTab(cust.ID), sanitizeTab(cust.Domain))
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ResellerCustomersGetCmd struct {
+ Customer string `arg:"" name:"customer" help:"Customer ID or domain"`
+}
+
+func (c *ResellerCustomersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ customer := strings.TrimSpace(c.Customer)
+ if customer == "" {
+ return usage("customer is required")
+ }
+
+ svc, err := newResellerService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Customers.Get(customer).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get customer %s: %w", customer, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Customer ID: %s\n", resp.CustomerId)
+ u.Out().Printf("Domain: %s\n", resp.CustomerDomain)
+ if resp.CustomerType != "" {
+ u.Out().Printf("Type: %s\n", resp.CustomerType)
+ }
+ if resp.PrimaryAdmin != nil && resp.PrimaryAdmin.PrimaryEmail != "" {
+ u.Out().Printf("Primary Admin: %s\n", resp.PrimaryAdmin.PrimaryEmail)
+ }
+ return nil
+}
+
+type ResellerCustomersCreateCmd struct {
+ Domain string `name:"domain" help:"Customer domain" required:""`
+ AdminEmail string `name:"admin-email" help:"Primary admin email" required:""`
+ AlternateEmail string `name:"alternate-email" help:"Alternate email"`
+ Type string `name:"type" default:"domain" enum:"domain,team" help:"Customer type"`
+ Phone string `name:"phone" help:"Phone number"`
+}
+
+func (c *ResellerCustomersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ domain := strings.TrimSpace(c.Domain)
+ adminEmail := strings.TrimSpace(c.AdminEmail)
+ if domain == "" || adminEmail == "" {
+ return usage("--domain and --admin-email are required")
+ }
+
+ svc, err := newResellerService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ customer := &reseller.Customer{
+ CustomerDomain: domain,
+ CustomerType: c.Type,
+ PrimaryAdmin: &reseller.PrimaryAdmin{PrimaryEmail: adminEmail},
+ }
+ if strings.TrimSpace(c.AlternateEmail) != "" {
+ customer.AlternateEmail = strings.TrimSpace(c.AlternateEmail)
+ }
+ if strings.TrimSpace(c.Phone) != "" {
+ customer.PhoneNumber = strings.TrimSpace(c.Phone)
+ }
+
+ resp, err := svc.Customers.Insert(customer).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create customer: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Created customer %s (%s)\n", resp.CustomerId, resp.CustomerDomain)
+ return nil
+}
+
+type ResellerSubscriptionsCmd struct {
+ List ResellerSubscriptionsListCmd `cmd:"" name:"list" aliases:"ls" help:"List reseller subscriptions"`
+ Get ResellerSubscriptionsGetCmd `cmd:"" name:"get" help:"Get reseller subscription"`
+ Create ResellerSubscriptionsCreateCmd `cmd:"" name:"create" help:"Create reseller subscription"`
+}
+
+type ResellerSubscriptionsListCmd struct {
+ Customer string `name:"customer" help:"Customer ID"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+ Prefix string `name:"prefix" help:"Customer name prefix filter"`
+}
+
+func (c *ResellerSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newResellerService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Subscriptions.List().MaxResults(c.Max)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ if strings.TrimSpace(c.Customer) != "" {
+ call = call.CustomerId(strings.TrimSpace(c.Customer))
+ }
+ if strings.TrimSpace(c.Prefix) != "" {
+ call = call.CustomerNamePrefix(strings.TrimSpace(c.Prefix))
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list subscriptions: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Subscriptions) == 0 {
+ u.Err().Println("No subscriptions found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "CUSTOMER\tSUBSCRIPTION\tSKU\tPLAN\tSTATUS")
+ for _, sub := range resp.Subscriptions {
+ if sub == nil {
+ continue
+ }
+ plan := ""
+ if sub.Plan != nil {
+ plan = sub.Plan.PlanName
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
+ sanitizeTab(sub.CustomerId),
+ sanitizeTab(sub.SubscriptionId),
+ sanitizeTab(sub.SkuId),
+ sanitizeTab(plan),
+ sanitizeTab(sub.Status),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ResellerSubscriptionsGetCmd struct {
+ Customer string `arg:"" name:"customer" help:"Customer ID"`
+ Subscription string `arg:"" name:"subscription" help:"Subscription ID"`
+}
+
+func (c *ResellerSubscriptionsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ customer := strings.TrimSpace(c.Customer)
+ subscription := strings.TrimSpace(c.Subscription)
+ if customer == "" || subscription == "" {
+ return usage("customer and subscription are required")
+ }
+
+ svc, err := newResellerService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Subscriptions.Get(customer, subscription).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get subscription %s: %w", subscription, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Customer: %s\n", resp.CustomerId)
+ u.Out().Printf("Subscription: %s\n", resp.SubscriptionId)
+ u.Out().Printf("SKU: %s\n", resp.SkuId)
+ if resp.Plan != nil {
+ u.Out().Printf("Plan: %s\n", resp.Plan.PlanName)
+ }
+ if resp.Status != "" {
+ u.Out().Printf("Status: %s\n", resp.Status)
+ }
+ return nil
+}
+
+type ResellerSubscriptionsCreateCmd struct {
+ Customer string `name:"customer" help:"Customer ID" required:""`
+ Plan string `name:"plan" help:"Plan name (FLEXIBLE, ANNUAL_MONTHLY_PAY, ANNUAL_YEARLY_PAY, TRIAL, FREE)" required:""`
+ SKU string `name:"sku" help:"SKU ID" required:""`
+ Seats int64 `name:"seats" help:"Number of seats for annual plans"`
+ MaxSeats int64 `name:"max-seats" help:"Maximum seats for flexible/trial plans"`
+}
+
+func (c *ResellerSubscriptionsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ customer := strings.TrimSpace(c.Customer)
+ plan := strings.ToUpper(strings.TrimSpace(c.Plan))
+ sku := strings.TrimSpace(c.SKU)
+ if customer == "" || plan == "" || sku == "" {
+ return usage("--customer, --plan, and --sku are required")
+ }
+
+ svc, err := newResellerService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ seats := &reseller.Seats{}
+ if plan == "FLEXIBLE" || plan == "TRIAL" || plan == "FREE" {
+ if c.MaxSeats > 0 {
+ seats.MaximumNumberOfSeats = c.MaxSeats
+ } else if c.Seats > 0 {
+ seats.MaximumNumberOfSeats = c.Seats
+ }
+ } else {
+ if c.Seats > 0 {
+ seats.NumberOfSeats = c.Seats
+ } else if c.MaxSeats > 0 {
+ seats.NumberOfSeats = c.MaxSeats
+ }
+ }
+ if seats.NumberOfSeats == 0 && seats.MaximumNumberOfSeats == 0 {
+ return usage("--seats or --max-seats is required")
+ }
+
+ subscription := &reseller.Subscription{
+ SkuId: sku,
+ Plan: &reseller.SubscriptionPlan{PlanName: plan},
+ Seats: seats,
+ }
+
+ resp, err := svc.Subscriptions.Insert(customer, subscription).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create subscription: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Created subscription %s for %s\n", resp.SubscriptionId, resp.CustomerId)
+ return nil
+}
diff --git a/internal/cmd/reseller_test.go b/internal/cmd/reseller_test.go
new file mode 100644
index 00000000..c1c7f0f1
--- /dev/null
+++ b/internal/cmd/reseller_test.go
@@ -0,0 +1,723 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/option"
+ "google.golang.org/api/reseller/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func newResellerServiceStub(t *testing.T, handler http.HandlerFunc) (*reseller.Service, func()) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ svc, err := reseller.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ srv.Close()
+ t.Fatalf("NewService: %v", err)
+ }
+ return svc, srv.Close
+}
+
+func stubResellerService(t *testing.T, svc *reseller.Service) {
+ t.Helper()
+ orig := newResellerService
+ t.Cleanup(func() { newResellerService = orig })
+ newResellerService = func(context.Context, string) (*reseller.Service, error) { return svc, nil }
+}
+
+func TestResellerCustomersListCmd(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{{
+ "customerId": "C123",
+ "customerDomain": "example.com",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResellerSubscriptionsCreateCmd(t *testing.T) {
+ var gotPlan string
+ var gotSeats int64
+
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload reseller.Subscription
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ if payload.Plan != nil {
+ gotPlan = payload.Plan.PlanName
+ }
+ if payload.Seats != nil {
+ gotSeats = payload.Seats.NumberOfSeats
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptionId": "sub1",
+ "customerId": "C1",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsCreateCmd{
+ Customer: "C1",
+ Plan: "ANNUAL_MONTHLY_PAY",
+ SKU: "sku1",
+ Seats: 5,
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPlan != "ANNUAL_MONTHLY_PAY" || gotSeats != 5 {
+ t.Fatalf("unexpected payload: plan=%s seats=%d", gotPlan, gotSeats)
+ }
+ if !strings.Contains(out, "Created subscription") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResellerCustomersGetCmd(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "customerId": "C123",
+ "customerDomain": "example.com",
+ "customerType": "domain",
+ "primaryAdmin": map[string]any{"primaryEmail": "admin@example.com"},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersGetCmd{Customer: "C123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "C123") {
+ t.Fatalf("expected customer ID in output, got: %s", out)
+ }
+ if !strings.Contains(out, "example.com") {
+ t.Fatalf("expected domain in output, got: %s", out)
+ }
+}
+
+func TestResellerCustomersGetCmd_JSON(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "customerId": "C123",
+ "customerDomain": "example.com",
+ "customerType": "domain",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersGetCmd{Customer: "C123"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"customerId"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResellerCustomersGetCmd_EmptyCustomer(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersGetCmd{Customer: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty customer")
+ }
+ if !strings.Contains(err.Error(), "customer is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResellerCustomersGetCmd_APIError(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 404,
+ "message": "Customer not found",
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersGetCmd{Customer: "NOTFOUND"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+ if !strings.Contains(err.Error(), "get customer") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResellerCustomersGetCmd_WithPrimaryAdmin(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "customerId": "C123",
+ "customerDomain": "example.com",
+ "customerType": "team",
+ "primaryAdmin": map[string]any{"primaryEmail": "admin@example.com"},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersGetCmd{Customer: "C123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Primary Admin:") || !strings.Contains(out, "admin@example.com") {
+ t.Fatalf("expected primary admin in output, got: %s", out)
+ }
+ if !strings.Contains(out, "Type:") || !strings.Contains(out, "team") {
+ t.Fatalf("expected type in output, got: %s", out)
+ }
+}
+
+func TestResellerSubscriptionsListCmd(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{
+ {
+ "customerId": "C123",
+ "subscriptionId": "sub1",
+ "skuId": "Google-Apps-Unlimited",
+ "plan": map[string]any{"planName": "ANNUAL"},
+ "status": "ACTIVE",
+ },
+ {
+ "customerId": "C456",
+ "subscriptionId": "sub2",
+ "skuId": "Google-Vault",
+ "plan": map[string]any{"planName": "FLEXIBLE"},
+ "status": "ACTIVE",
+ },
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "C123") || !strings.Contains(out, "sub1") {
+ t.Fatalf("expected subscription data in output, got: %s", out)
+ }
+ if !strings.Contains(out, "ANNUAL") {
+ t.Fatalf("expected plan name in output, got: %s", out)
+ }
+}
+
+func TestResellerSubscriptionsListCmd_JSON(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{
+ {
+ "customerId": "C123",
+ "subscriptionId": "sub1",
+ "skuId": "Google-Apps-Unlimited",
+ },
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"subscriptions"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResellerSubscriptionsListCmd_Empty(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsListCmd{}
+
+ stderr := captureStderr(t, func() {
+ if err := cmd.Run(testContextWithStderr(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(stderr, "No subscriptions found") {
+ t.Fatalf("expected 'No subscriptions found' message, got: %s", stderr)
+ }
+}
+
+func TestResellerSubscriptionsListCmd_WithFilters(t *testing.T) {
+ var gotCustomer, gotPrefix string
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ gotCustomer = r.URL.Query().Get("customerId")
+ gotPrefix = r.URL.Query().Get("customerNamePrefix")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{
+ {
+ "customerId": "C123",
+ "subscriptionId": "sub1",
+ "skuId": "Google-Apps-Unlimited",
+ },
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsListCmd{Customer: "C123", Prefix: "test"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotCustomer != "C123" {
+ t.Fatalf("expected customerId filter, got: %s", gotCustomer)
+ }
+ if gotPrefix != "test" {
+ t.Fatalf("expected prefix filter, got: %s", gotPrefix)
+ }
+}
+
+func TestResellerSubscriptionsListCmd_APIError(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 500,
+ "message": "Internal error",
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+ if !strings.Contains(err.Error(), "list subscriptions") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResellerSubscriptionsGetCmd(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123/subscriptions/sub1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "customerId": "C123",
+ "subscriptionId": "sub1",
+ "skuId": "Google-Apps-Unlimited",
+ "plan": map[string]any{"planName": "ANNUAL"},
+ "status": "ACTIVE",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "sub1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Customer:") || !strings.Contains(out, "C123") {
+ t.Fatalf("expected customer in output, got: %s", out)
+ }
+ if !strings.Contains(out, "Subscription:") || !strings.Contains(out, "sub1") {
+ t.Fatalf("expected subscription in output, got: %s", out)
+ }
+ if !strings.Contains(out, "Plan:") || !strings.Contains(out, "ANNUAL") {
+ t.Fatalf("expected plan in output, got: %s", out)
+ }
+}
+
+func TestResellerSubscriptionsGetCmd_JSON(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123/subscriptions/sub1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "customerId": "C123",
+ "subscriptionId": "sub1",
+ "skuId": "Google-Apps-Unlimited",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "sub1"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"subscriptionId"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResellerSubscriptionsGetCmd_EmptyCustomer(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsGetCmd{Customer: " ", Subscription: "sub1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty customer")
+ }
+ if !strings.Contains(err.Error(), "customer and subscription are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResellerSubscriptionsGetCmd_EmptySubscription(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: ""}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty subscription")
+ }
+ if !strings.Contains(err.Error(), "customer and subscription are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResellerSubscriptionsGetCmd_APIError(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 404,
+ "message": "Subscription not found",
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "NOTFOUND"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+ if !strings.Contains(err.Error(), "get subscription") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResellerSubscriptionsGetCmd_WithStatus(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123/subscriptions/sub1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "customerId": "C123",
+ "subscriptionId": "sub1",
+ "skuId": "Google-Apps-Unlimited",
+ "status": "SUSPENDED",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "sub1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Status:") || !strings.Contains(out, "SUSPENDED") {
+ t.Fatalf("expected status in output, got: %s", out)
+ }
+}
+
+func TestResellerCustomersListCmd_JSON(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{{
+ "customerId": "C123",
+ "customerDomain": "example.com",
+ }},
+ "nextPageToken": "token123",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"customers"`) {
+ t.Fatalf("expected JSON output with customers, got: %s", out)
+ }
+ if !strings.Contains(out, `"nextPageToken"`) {
+ t.Fatalf("expected nextPageToken in JSON output, got: %s", out)
+ }
+}
+
+func TestResellerCustomersListCmd_Empty(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersListCmd{}
+
+ stderr := captureStderr(t, func() {
+ if err := cmd.Run(testContextWithStderr(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(stderr, "No customers found") {
+ t.Fatalf("expected 'No customers found' message, got: %s", stderr)
+ }
+}
+
+func TestResellerCustomersListCmd_WithPrefix(t *testing.T) {
+ var gotPrefix string
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPrefix = r.URL.Query().Get("customerNamePrefix")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{{
+ "customerId": "C123",
+ "customerDomain": "example.com",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersListCmd{Prefix: "test"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPrefix != "test" {
+ t.Fatalf("expected prefix filter, got: %s", gotPrefix)
+ }
+}
+
+func TestResellerCustomersListCmd_DedupesCustomers(t *testing.T) {
+ svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ // Return multiple subscriptions for the same customer
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subscriptions": []map[string]any{
+ {"customerId": "C123", "customerDomain": "example.com", "skuId": "sku1"},
+ {"customerId": "C123", "customerDomain": "example.com", "skuId": "sku2"},
+ {"customerId": "C456", "customerDomain": "other.com", "skuId": "sku3"},
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubResellerService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResellerCustomersListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should only have 2 customers, not 3 rows
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ // Header + 2 customers = 3 lines
+ if len(lines) != 3 {
+ t.Fatalf("expected 3 lines (header + 2 customers), got %d: %s", len(lines), out)
+ }
+}
diff --git a/internal/cmd/resources.go b/internal/cmd/resources.go
new file mode 100644
index 00000000..450b9a7b
--- /dev/null
+++ b/internal/cmd/resources.go
@@ -0,0 +1,7 @@
+package cmd
+
+type ResourcesCmd struct {
+ Buildings ResourcesBuildingsCmd `cmd:"" name:"buildings" help:"Manage resource buildings"`
+ Calendars ResourcesCalendarsCmd `cmd:"" name:"calendars" help:"Manage resource calendars"`
+ Features ResourcesFeaturesCmd `cmd:"" name:"features" help:"Manage resource features"`
+}
diff --git a/internal/cmd/resources_buildings.go b/internal/cmd/resources_buildings.go
new file mode 100644
index 00000000..074f0ddb
--- /dev/null
+++ b/internal/cmd/resources_buildings.go
@@ -0,0 +1,248 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type ResourcesBuildingsCmd struct {
+ List ResourcesBuildingsListCmd `cmd:"" name:"list" aliases:"ls" help:"List buildings"`
+ Get ResourcesBuildingsGetCmd `cmd:"" name:"get" help:"Get building"`
+ Create ResourcesBuildingsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create building"`
+ Update ResourcesBuildingsUpdateCmd `cmd:"" name:"update" help:"Update building"`
+ Delete ResourcesBuildingsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete building"`
+}
+
+type ResourcesBuildingsListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ResourcesBuildingsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Resources.Buildings.List(adminCustomerID())
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list buildings: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Buildings) == 0 {
+ u.Err().Println("No buildings found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ID\tNAME\tFLOORS\tDESCRIPTION")
+ for _, building := range resp.Buildings {
+ if building == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(building.BuildingId),
+ sanitizeTab(building.BuildingName),
+ sanitizeTab(strings.Join(building.FloorNames, ", ")),
+ sanitizeTab(building.Description),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ResourcesBuildingsGetCmd struct {
+ BuildingID string `arg:"" name:"building-id" help:"Building ID"`
+}
+
+func (c *ResourcesBuildingsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ buildingID := strings.TrimSpace(c.BuildingID)
+ if buildingID == "" {
+ return usage("building ID is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ building, err := svc.Resources.Buildings.Get(adminCustomerID(), buildingID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get building %s: %w", buildingID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, building)
+ }
+
+ u.Out().Printf("ID: %s\n", building.BuildingId)
+ u.Out().Printf("Name: %s\n", building.BuildingName)
+ if building.Description != "" {
+ u.Out().Printf("Description: %s\n", building.Description)
+ }
+ if len(building.FloorNames) > 0 {
+ u.Out().Printf("Floors: %s\n", strings.Join(building.FloorNames, ", "))
+ }
+ return nil
+}
+
+type ResourcesBuildingsCreateCmd struct {
+ Name string `name:"name" help:"Building name" required:""`
+ Description string `name:"description" help:"Building description"`
+ Floors string `name:"floors" help:"Comma-separated floor names"`
+}
+
+func (c *ResourcesBuildingsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("--name is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ building := &admin.Building{
+ BuildingName: name,
+ Description: c.Description,
+ }
+ if floors := splitCSV(c.Floors); len(floors) > 0 {
+ building.FloorNames = floors
+ }
+
+ created, err := svc.Resources.Buildings.Insert(adminCustomerID(), building).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create building %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created building: %s (%s)\n", created.BuildingName, created.BuildingId)
+ return nil
+}
+
+type ResourcesBuildingsUpdateCmd struct {
+ BuildingID string `arg:"" name:"building-id" help:"Building ID"`
+ Name *string `name:"name" help:"Building name"`
+ Description *string `name:"description" help:"Building description"`
+}
+
+func (c *ResourcesBuildingsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ buildingID := strings.TrimSpace(c.BuildingID)
+ if buildingID == "" {
+ return usage("building ID is required")
+ }
+
+ if c.Name == nil && c.Description == nil {
+ return usage("no updates specified")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ patch := &admin.Building{}
+ if c.Name != nil {
+ patch.BuildingName = strings.TrimSpace(*c.Name)
+ }
+ if c.Description != nil {
+ patch.Description = strings.TrimSpace(*c.Description)
+ }
+
+ updated, err := svc.Resources.Buildings.Patch(adminCustomerID(), buildingID, patch).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update building %s: %w", buildingID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated building: %s (%s)\n", updated.BuildingName, updated.BuildingId)
+ return nil
+}
+
+type ResourcesBuildingsDeleteCmd struct {
+ BuildingID string `arg:"" name:"building-id" help:"Building ID"`
+}
+
+func (c *ResourcesBuildingsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ buildingID := strings.TrimSpace(c.BuildingID)
+ if buildingID == "" {
+ return usage("building ID is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete building %s", buildingID)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Resources.Buildings.Delete(adminCustomerID(), buildingID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete building %s: %w", buildingID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"buildingId": buildingID, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted building: %s\n", buildingID)
+ return nil
+}
diff --git a/internal/cmd/resources_calendars.go b/internal/cmd/resources_calendars.go
new file mode 100644
index 00000000..c28e16d0
--- /dev/null
+++ b/internal/cmd/resources_calendars.go
@@ -0,0 +1,276 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type ResourcesCalendarsCmd struct {
+ List ResourcesCalendarsListCmd `cmd:"" name:"list" aliases:"ls" help:"List calendar resources"`
+ Get ResourcesCalendarsGetCmd `cmd:"" name:"get" help:"Get calendar resource"`
+ Create ResourcesCalendarsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create calendar resource"`
+ Update ResourcesCalendarsUpdateCmd `cmd:"" name:"update" help:"Update calendar resource"`
+ Delete ResourcesCalendarsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete calendar resource"`
+}
+
+type ResourcesCalendarsListCmd struct {
+ Building string `name:"building" help:"Filter by building ID"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ResourcesCalendarsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Resources.Calendars.List(adminCustomerID())
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list calendar resources: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ items := resp.Items
+ if building := strings.TrimSpace(c.Building); building != "" {
+ filtered := make([]*admin.CalendarResource, 0, len(items))
+ for _, item := range items {
+ if item != nil && item.BuildingId == building {
+ filtered = append(filtered, item)
+ }
+ }
+ items = filtered
+ }
+
+ if len(items) == 0 {
+ u.Err().Println("No calendar resources found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "RESOURCE ID\tNAME\tEMAIL\tCATEGORY\tBUILDING\tFLOOR\tCAPACITY")
+ for _, resource := range items {
+ if resource == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%d\n",
+ sanitizeTab(resource.ResourceId),
+ sanitizeTab(resource.ResourceName),
+ sanitizeTab(resource.ResourceEmail),
+ sanitizeTab(resource.ResourceCategory),
+ sanitizeTab(resource.BuildingId),
+ sanitizeTab(resource.FloorName),
+ resource.Capacity,
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ResourcesCalendarsGetCmd struct {
+ ResourceID string `arg:"" name:"resource-id" help:"Resource ID"`
+}
+
+func (c *ResourcesCalendarsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ resourceID := strings.TrimSpace(c.ResourceID)
+ if resourceID == "" {
+ return usage("resource ID is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resource, err := svc.Resources.Calendars.Get(adminCustomerID(), resourceID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get calendar resource %s: %w", resourceID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resource)
+ }
+
+ u.Out().Printf("ID: %s\n", resource.ResourceId)
+ u.Out().Printf("Name: %s\n", resource.ResourceName)
+ u.Out().Printf("Email: %s\n", resource.ResourceEmail)
+ u.Out().Printf("Category: %s\n", resource.ResourceCategory)
+ if resource.ResourceDescription != "" {
+ u.Out().Printf("Description: %s\n", resource.ResourceDescription)
+ }
+ if resource.UserVisibleDescription != "" {
+ u.Out().Printf("User Desc: %s\n", resource.UserVisibleDescription)
+ }
+ if resource.BuildingId != "" {
+ u.Out().Printf("Building: %s\n", resource.BuildingId)
+ }
+ if resource.FloorName != "" {
+ u.Out().Printf("Floor: %s\n", resource.FloorName)
+ }
+ if resource.Capacity != 0 {
+ u.Out().Printf("Capacity: %d\n", resource.Capacity)
+ }
+ return nil
+}
+
+type ResourcesCalendarsCreateCmd struct {
+ Name string `name:"name" help:"Resource name" required:""`
+ Type string `name:"type" help:"Resource category: CONFERENCE_ROOM|OTHER" enum:"CONFERENCE_ROOM,OTHER" required:""`
+ Building string `name:"building" help:"Building ID"`
+ Floor string `name:"floor" help:"Floor name"`
+ Capacity int64 `name:"capacity" help:"Capacity"`
+}
+
+func (c *ResourcesCalendarsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("--name is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resource := &admin.CalendarResource{
+ ResourceName: name,
+ ResourceCategory: c.Type,
+ BuildingId: strings.TrimSpace(c.Building),
+ FloorName: strings.TrimSpace(c.Floor),
+ Capacity: c.Capacity,
+ }
+
+ created, err := svc.Resources.Calendars.Insert(adminCustomerID(), resource).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create calendar resource %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created calendar resource: %s (%s)\n", created.ResourceName, created.ResourceId)
+ return nil
+}
+
+type ResourcesCalendarsUpdateCmd struct {
+ ResourceID string `arg:"" name:"resource-id" help:"Resource ID"`
+ Name *string `name:"name" help:"Resource name"`
+ Capacity *int64 `name:"capacity" help:"Capacity"`
+}
+
+func (c *ResourcesCalendarsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ resourceID := strings.TrimSpace(c.ResourceID)
+ if resourceID == "" {
+ return usage("resource ID is required")
+ }
+
+ if c.Name == nil && c.Capacity == nil {
+ return usage("no updates specified")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ patch := &admin.CalendarResource{}
+ if c.Name != nil {
+ patch.ResourceName = strings.TrimSpace(*c.Name)
+ }
+ if c.Capacity != nil {
+ patch.Capacity = *c.Capacity
+ }
+
+ updated, err := svc.Resources.Calendars.Patch(adminCustomerID(), resourceID, patch).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update calendar resource %s: %w", resourceID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated calendar resource: %s (%s)\n", updated.ResourceName, updated.ResourceId)
+ return nil
+}
+
+type ResourcesCalendarsDeleteCmd struct {
+ ResourceID string `arg:"" name:"resource-id" help:"Resource ID"`
+}
+
+func (c *ResourcesCalendarsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ resourceID := strings.TrimSpace(c.ResourceID)
+ if resourceID == "" {
+ return usage("resource ID is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete calendar resource %s", resourceID)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Resources.Calendars.Delete(adminCustomerID(), resourceID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete calendar resource %s: %w", resourceID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"resourceId": resourceID, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted calendar resource: %s\n", resourceID)
+ return nil
+}
diff --git a/internal/cmd/resources_features.go b/internal/cmd/resources_features.go
new file mode 100644
index 00000000..c6aba53d
--- /dev/null
+++ b/internal/cmd/resources_features.go
@@ -0,0 +1,143 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type ResourcesFeaturesCmd struct {
+ List ResourcesFeaturesListCmd `cmd:"" name:"list" aliases:"ls" help:"List resource features"`
+ Create ResourcesFeaturesCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create resource feature"`
+ Delete ResourcesFeaturesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete resource feature"`
+}
+
+type ResourcesFeaturesListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ResourcesFeaturesListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Resources.Features.List(adminCustomerID())
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list features: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Features) == 0 {
+ u.Err().Println("No features found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "NAME")
+ for _, feature := range resp.Features {
+ if feature == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\n", sanitizeTab(feature.Name))
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ResourcesFeaturesCreateCmd struct {
+ Name string `name:"name" help:"Feature name" required:""`
+}
+
+func (c *ResourcesFeaturesCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("--name is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ feature := &admin.Feature{Name: name}
+ created, err := svc.Resources.Features.Insert(adminCustomerID(), feature).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create feature %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created feature: %s\n", created.Name)
+ return nil
+}
+
+type ResourcesFeaturesDeleteCmd struct {
+ Name string `arg:"" name:"name" help:"Feature name"`
+}
+
+func (c *ResourcesFeaturesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("feature name is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete feature %s", name)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Resources.Features.Delete(adminCustomerID(), name).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete feature %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"name": name, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted feature: %s\n", name)
+ return nil
+}
diff --git a/internal/cmd/resources_test.go b/internal/cmd/resources_test.go
new file mode 100644
index 00000000..231d2fff
--- /dev/null
+++ b/internal/cmd/resources_test.go
@@ -0,0 +1,1274 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+// =============================================================================
+// Buildings Tests
+// =============================================================================
+
+func TestResourcesBuildingsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildings": []map[string]any{
+ {"buildingId": "b1", "buildingName": "HQ", "floorNames": []string{"1", "2"}},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "HQ") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesBuildingsListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildings": []map[string]any{
+ {"buildingId": "b1", "buildingName": "HQ", "floorNames": []string{"1", "2"}},
+ {"buildingId": "b2", "buildingName": "Annex", "description": "Secondary building"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "buildings") || !strings.Contains(out, "b1") {
+ t.Fatalf("expected JSON buildings output, got: %s", out)
+ }
+}
+
+func TestResourcesBuildingsListCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildings": []map[string]any{},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsListCmd{}
+
+ // Should not error on empty list
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestResourcesBuildingsListCmd_Pagination(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") {
+ http.NotFound(w, r)
+ return
+ }
+ // Check pagination parameters
+ if r.URL.Query().Get("maxResults") != "10" {
+ t.Errorf("expected maxResults=10, got %s", r.URL.Query().Get("maxResults"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildings": []map[string]any{
+ {"buildingId": "b1", "buildingName": "HQ"},
+ },
+ "nextPageToken": "token123",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsListCmd{Max: 10}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestResourcesBuildingsGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings/b1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildingId": "b1",
+ "buildingName": "Headquarters",
+ "description": "Main office building",
+ "floorNames": []string{"Ground", "1", "2"},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsGetCmd{BuildingID: "b1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "ID:") || !strings.Contains(out, "b1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "Name:") || !strings.Contains(out, "Headquarters") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesBuildingsGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings/b1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildingId": "b1",
+ "buildingName": "Headquarters",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsGetCmd{BuildingID: "b1"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "buildingId") || !strings.Contains(out, "b1") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesBuildingsGetCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsGetCmd{BuildingID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty building ID")
+ }
+ if !strings.Contains(err.Error(), "building ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesBuildingsCreateCmd(t *testing.T) {
+ var gotPayload struct {
+ BuildingName string `json:"buildingName"`
+ Description string `json:"description"`
+ FloorNames []string `json:"floorNames"`
+ }
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/buildings") {
+ http.NotFound(w, r)
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildingId": "b-new",
+ "buildingName": gotPayload.BuildingName,
+ "description": gotPayload.Description,
+ "floorNames": gotPayload.FloorNames,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsCreateCmd{
+ Name: "New Building",
+ Description: "A new office building",
+ Floors: "Ground, 1, 2, 3",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPayload.BuildingName != "New Building" {
+ t.Errorf("expected name 'New Building', got %q", gotPayload.BuildingName)
+ }
+ if gotPayload.Description != "A new office building" {
+ t.Errorf("expected description 'A new office building', got %q", gotPayload.Description)
+ }
+ if len(gotPayload.FloorNames) != 4 {
+ t.Errorf("expected 4 floors, got %d", len(gotPayload.FloorNames))
+ }
+ if !strings.Contains(out, "Created building") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesBuildingsCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/buildings") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildingId": "b-new",
+ "buildingName": "New Building",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsCreateCmd{Name: "New Building"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "buildingId") || !strings.Contains(out, "b-new") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesBuildingsCreateCmd_EmptyName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsCreateCmd{Name: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty name")
+ }
+ if !strings.Contains(err.Error(), "--name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesBuildingsUpdateCmd(t *testing.T) {
+ var gotPayload struct {
+ BuildingName string `json:"buildingName"`
+ Description string `json:"description"`
+ }
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/buildings/b1") {
+ http.NotFound(w, r)
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildingId": "b1",
+ "buildingName": gotPayload.BuildingName,
+ "description": gotPayload.Description,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated HQ"
+ newDesc := "Updated description"
+ cmd := &ResourcesBuildingsUpdateCmd{
+ BuildingID: "b1",
+ Name: &newName,
+ Description: &newDesc,
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPayload.BuildingName != "Updated HQ" {
+ t.Errorf("expected name 'Updated HQ', got %q", gotPayload.BuildingName)
+ }
+ if gotPayload.Description != "Updated description" {
+ t.Errorf("expected description 'Updated description', got %q", gotPayload.Description)
+ }
+ if !strings.Contains(out, "Updated building") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesBuildingsUpdateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/buildings/b1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "buildingId": "b1",
+ "buildingName": "Updated HQ",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated HQ"
+ cmd := &ResourcesBuildingsUpdateCmd{BuildingID: "b1", Name: &newName}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "buildingId") || !strings.Contains(out, "b1") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesBuildingsUpdateCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated"
+ cmd := &ResourcesBuildingsUpdateCmd{BuildingID: " ", Name: &newName}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty building ID")
+ }
+ if !strings.Contains(err.Error(), "building ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesBuildingsUpdateCmd_NoUpdates(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesBuildingsUpdateCmd{BuildingID: "b1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no updates")
+ }
+ if !strings.Contains(err.Error(), "no updates specified") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesBuildingsDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/buildings/b1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesBuildingsDeleteCmd{BuildingID: "b1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted building") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesBuildingsDeleteCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/buildings/b1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesBuildingsDeleteCmd{BuildingID: "b1"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "deleted") || !strings.Contains(out, "true") {
+ t.Fatalf("expected JSON output with deleted:true, got: %s", out)
+ }
+}
+
+func TestResourcesBuildingsDeleteCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesBuildingsDeleteCmd{BuildingID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty building ID")
+ }
+ if !strings.Contains(err.Error(), "building ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// =============================================================================
+// Calendars Tests
+// =============================================================================
+
+func TestResourcesCalendarsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "resourceId": "r1",
+ "resourceName": "Conference Room A",
+ "resourceEmail": "room-a@example.com",
+ "resourceCategory": "CONFERENCE_ROOM",
+ "buildingId": "b1",
+ "floorName": "1",
+ "capacity": 10,
+ },
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Conference Room A") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesCalendarsListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"resourceId": "r1", "resourceName": "Conference Room A"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "items") || !strings.Contains(out, "r1") {
+ t.Fatalf("expected JSON items output, got: %s", out)
+ }
+}
+
+func TestResourcesCalendarsListCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsListCmd{}
+
+ // Should not error on empty list
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestResourcesCalendarsListCmd_FilterByBuilding(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"resourceId": "r1", "resourceName": "Room A", "buildingId": "b1"},
+ {"resourceId": "r2", "resourceName": "Room B", "buildingId": "b2"},
+ {"resourceId": "r3", "resourceName": "Room C", "buildingId": "b1"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsListCmd{Building: "b1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should show only rooms in b1
+ if !strings.Contains(out, "Room A") || !strings.Contains(out, "Room C") {
+ t.Fatalf("expected rooms from b1, got: %s", out)
+ }
+ if strings.Contains(out, "Room B") {
+ t.Fatalf("should not contain Room B (b2), got: %s", out)
+ }
+}
+
+func TestResourcesCalendarsGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars/r1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r1",
+ "resourceName": "Conference Room A",
+ "resourceEmail": "room-a@example.com",
+ "resourceCategory": "CONFERENCE_ROOM",
+ "resourceDescription": "Main conference room",
+ "userVisibleDescription": "Large room with projector",
+ "buildingId": "b1",
+ "floorName": "1",
+ "capacity": 15,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsGetCmd{ResourceID: "r1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "ID:") || !strings.Contains(out, "r1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "Name:") || !strings.Contains(out, "Conference Room A") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "Email:") || !strings.Contains(out, "room-a@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesCalendarsGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars/r1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r1",
+ "resourceName": "Conference Room A",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsGetCmd{ResourceID: "r1"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "resourceId") || !strings.Contains(out, "r1") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesCalendarsGetCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsGetCmd{ResourceID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty resource ID")
+ }
+ if !strings.Contains(err.Error(), "resource ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesCalendarsCreateCmd(t *testing.T) {
+ var gotName string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ ResourceName string `json:"resourceName"`
+ ResourceCategory string `json:"resourceCategory"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotName = payload.ResourceName
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r1",
+ "resourceName": payload.ResourceName,
+ "resourceEmail": "room@example.com",
+ "resourceCategory": payload.ResourceCategory,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsCreateCmd{Name: "Training Room", Type: "CONFERENCE_ROOM"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotName != "Training Room" {
+ t.Fatalf("unexpected name: %q", gotName)
+ }
+ if !strings.Contains(out, "Created calendar resource") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesCalendarsCreateCmd_WithAllFields(t *testing.T) {
+ var gotPayload struct {
+ ResourceName string `json:"resourceName"`
+ ResourceCategory string `json:"resourceCategory"`
+ BuildingId string `json:"buildingId"`
+ FloorName string `json:"floorName"`
+ Capacity int64 `json:"capacity"`
+ }
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r-new",
+ "resourceName": gotPayload.ResourceName,
+ "resourceEmail": "new-room@example.com",
+ "resourceCategory": gotPayload.ResourceCategory,
+ "buildingId": gotPayload.BuildingId,
+ "floorName": gotPayload.FloorName,
+ "capacity": gotPayload.Capacity,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsCreateCmd{
+ Name: "Large Meeting Room",
+ Type: "CONFERENCE_ROOM",
+ Building: "b1",
+ Floor: "2",
+ Capacity: 20,
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPayload.ResourceName != "Large Meeting Room" {
+ t.Errorf("expected name 'Large Meeting Room', got %q", gotPayload.ResourceName)
+ }
+ if gotPayload.ResourceCategory != "CONFERENCE_ROOM" {
+ t.Errorf("expected category 'CONFERENCE_ROOM', got %q", gotPayload.ResourceCategory)
+ }
+ if gotPayload.BuildingId != "b1" {
+ t.Errorf("expected buildingId 'b1', got %q", gotPayload.BuildingId)
+ }
+ if gotPayload.FloorName != "2" {
+ t.Errorf("expected floor '2', got %q", gotPayload.FloorName)
+ }
+ if gotPayload.Capacity != 20 {
+ t.Errorf("expected capacity 20, got %d", gotPayload.Capacity)
+ }
+ if !strings.Contains(out, "Created calendar resource") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesCalendarsCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/calendars") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r-new",
+ "resourceName": "New Room",
+ "resourceEmail": "new-room@example.com",
+ "resourceCategory": "OTHER",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsCreateCmd{Name: "New Room", Type: "OTHER"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "resourceId") || !strings.Contains(out, "r-new") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesCalendarsCreateCmd_EmptyName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsCreateCmd{Name: " ", Type: "CONFERENCE_ROOM"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty name")
+ }
+ if !strings.Contains(err.Error(), "--name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesCalendarsUpdateCmd(t *testing.T) {
+ var gotPayload struct {
+ ResourceName string `json:"resourceName"`
+ Capacity int64 `json:"capacity"`
+ }
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/calendars/r1") {
+ http.NotFound(w, r)
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r1",
+ "resourceName": gotPayload.ResourceName,
+ "capacity": gotPayload.Capacity,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated Room"
+ newCapacity := int64(25)
+ cmd := &ResourcesCalendarsUpdateCmd{
+ ResourceID: "r1",
+ Name: &newName,
+ Capacity: &newCapacity,
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPayload.ResourceName != "Updated Room" {
+ t.Errorf("expected name 'Updated Room', got %q", gotPayload.ResourceName)
+ }
+ if gotPayload.Capacity != 25 {
+ t.Errorf("expected capacity 25, got %d", gotPayload.Capacity)
+ }
+ if !strings.Contains(out, "Updated calendar resource") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesCalendarsUpdateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/calendars/r1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resourceId": "r1",
+ "resourceName": "Updated Room",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated Room"
+ cmd := &ResourcesCalendarsUpdateCmd{ResourceID: "r1", Name: &newName}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "resourceId") || !strings.Contains(out, "r1") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesCalendarsUpdateCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated"
+ cmd := &ResourcesCalendarsUpdateCmd{ResourceID: " ", Name: &newName}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty resource ID")
+ }
+ if !strings.Contains(err.Error(), "resource ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesCalendarsUpdateCmd_NoUpdates(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesCalendarsUpdateCmd{ResourceID: "r1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no updates")
+ }
+ if !strings.Contains(err.Error(), "no updates specified") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesCalendarsDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/calendars/r1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesCalendarsDeleteCmd{ResourceID: "r1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted calendar resource") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesCalendarsDeleteCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/calendars/r1") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesCalendarsDeleteCmd{ResourceID: "r1"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "deleted") || !strings.Contains(out, "true") {
+ t.Fatalf("expected JSON output with deleted:true, got: %s", out)
+ }
+}
+
+func TestResourcesCalendarsDeleteCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesCalendarsDeleteCmd{ResourceID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty resource ID")
+ }
+ if !strings.Contains(err.Error(), "resource ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// =============================================================================
+// Features Tests
+// =============================================================================
+
+func TestResourcesFeaturesListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "features": []map[string]any{
+ {"name": "Projector"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Projector") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesFeaturesListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "features": []map[string]any{
+ {"name": "Projector"},
+ {"name": "Whiteboard"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "features") || !strings.Contains(out, "Projector") {
+ t.Fatalf("expected JSON features output, got: %s", out)
+ }
+}
+
+func TestResourcesFeaturesListCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "features": []map[string]any{},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesListCmd{}
+
+ // Should not error on empty list
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestResourcesFeaturesListCmd_Pagination(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") {
+ http.NotFound(w, r)
+ return
+ }
+ // Check pagination parameters
+ if r.URL.Query().Get("maxResults") != "50" {
+ t.Errorf("expected maxResults=50, got %s", r.URL.Query().Get("maxResults"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "features": []map[string]any{
+ {"name": "Projector"},
+ },
+ "nextPageToken": "token456",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesListCmd{Max: 50}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestResourcesFeaturesCreateCmd(t *testing.T) {
+ var gotName string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/features") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotName = payload.Name
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": payload.Name,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesCreateCmd{Name: "Video Conferencing"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotName != "Video Conferencing" {
+ t.Fatalf("unexpected name: %q", gotName)
+ }
+ if !strings.Contains(out, "Created feature") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesFeaturesCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/features") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "New Feature",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesCreateCmd{Name: "New Feature"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "name") || !strings.Contains(out, "New Feature") {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestResourcesFeaturesCreateCmd_EmptyName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ResourcesFeaturesCreateCmd{Name: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty name")
+ }
+ if !strings.Contains(err.Error(), "--name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestResourcesFeaturesDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/features/Projector") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesFeaturesDeleteCmd{Name: "Projector"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted feature") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestResourcesFeaturesDeleteCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/features/Projector") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesFeaturesDeleteCmd{Name: "Projector"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "deleted") || !strings.Contains(out, "true") {
+ t.Fatalf("expected JSON output with deleted:true, got: %s", out)
+ }
+}
+
+func TestResourcesFeaturesDeleteCmd_EmptyName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ResourcesFeaturesDeleteCmd{Name: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty feature name")
+ }
+ if !strings.Contains(err.Error(), "feature name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
diff --git a/internal/cmd/roles.go b/internal/cmd/roles.go
new file mode 100644
index 00000000..e370c2e8
--- /dev/null
+++ b/internal/cmd/roles.go
@@ -0,0 +1,472 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type RolesCmd struct {
+ List RolesListCmd `cmd:"" name:"list" aliases:"ls" help:"List admin roles"`
+ Get RolesGetCmd `cmd:"" name:"get" help:"Get role details"`
+ Create RolesCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create admin role"`
+ Update RolesUpdateCmd `cmd:"" name:"update" help:"Update admin role"`
+ Delete RolesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete admin role"`
+ Privileges RolesPrivilegesCmd `cmd:"" name:"privileges" help:"List available privileges"`
+}
+
+type RolesListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *RolesListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Roles.List(adminCustomerID()).MaxResults(c.Max)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list roles: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Items) == 0 {
+ u.Err().Println("No roles found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ROLE ID\tNAME\tSYSTEM\tSUPERADMIN\tPRIVILEGES")
+ for _, role := range resp.Items {
+ if role == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%t\t%t\t%d\n",
+ sanitizeTab(strconv.FormatInt(role.RoleId, 10)),
+ sanitizeTab(role.RoleName),
+ role.IsSystemRole,
+ role.IsSuperAdminRole,
+ len(role.RolePrivileges),
+ )
+ }
+
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type RolesGetCmd struct {
+ Role string `arg:"" name:"role" help:"Role ID or name"`
+}
+
+func (c *RolesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ roleID, role, err := resolveRole(ctx, svc, c.Role)
+ if err != nil {
+ return err
+ }
+ if role == nil {
+ role, err = svc.Roles.Get(adminCustomerID(), roleID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get role %s: %w", c.Role, err)
+ }
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, role)
+ }
+
+ u.Out().Printf("Role ID: %s\n", strconv.FormatInt(role.RoleId, 10))
+ u.Out().Printf("Name: %s\n", role.RoleName)
+ u.Out().Printf("System Role: %v\n", role.IsSystemRole)
+ u.Out().Printf("Super Admin: %v\n", role.IsSuperAdminRole)
+ if role.RoleDescription != "" {
+ u.Out().Printf("Description: %s\n", role.RoleDescription)
+ }
+ if len(role.RolePrivileges) > 0 {
+ privs := make([]string, 0, len(role.RolePrivileges))
+ for _, p := range role.RolePrivileges {
+ if p == nil {
+ continue
+ }
+ privs = append(privs, p.PrivilegeName)
+ }
+ sort.Strings(privs)
+ u.Out().Printf("Privileges: %s\n", strings.Join(privs, ", "))
+ }
+ return nil
+}
+
+type RolesCreateCmd struct {
+ Name string `arg:"" name:"name" help:"Role name"`
+ Privileges string `name:"privileges" required:"" help:"Comma-separated privilege names"`
+ Description string `name:"description" help:"Role description"`
+}
+
+func (c *RolesCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ privNames := splitCSV(c.Privileges)
+ if len(privNames) == 0 {
+ return usage("--privileges is required")
+ }
+
+ privs, err := buildRolePrivileges(ctx, svc, privNames)
+ if err != nil {
+ return err
+ }
+
+ role := &admin.Role{
+ RoleName: c.Name,
+ RoleDescription: c.Description,
+ RolePrivileges: privs,
+ }
+
+ created, err := svc.Roles.Insert(adminCustomerID(), role).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create role %s: %w", c.Name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created role: %s (%d)\n", created.RoleName, created.RoleId)
+ return nil
+}
+
+type RolesUpdateCmd struct {
+ Role string `arg:"" name:"role" help:"Role ID or name"`
+ Name *string `name:"name" help:"New role name"`
+ Description *string `name:"description" help:"Role description"`
+ AddPrivileges string `name:"add-privileges" help:"Comma-separated privileges to add"`
+ RemovePrivileges string `name:"remove-privileges" help:"Comma-separated privileges to remove"`
+}
+
+func (c *RolesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ roleID, role, err := resolveRole(ctx, svc, c.Role)
+ if err != nil {
+ return err
+ }
+ if role == nil {
+ role, err = svc.Roles.Get(adminCustomerID(), roleID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get role %s: %w", c.Role, err)
+ }
+ }
+
+ hasUpdates := false
+ if c.Name != nil {
+ role.RoleName = *c.Name
+ hasUpdates = true
+ }
+ if c.Description != nil {
+ role.RoleDescription = *c.Description
+ if *c.Description == "" {
+ role.ForceSendFields = append(role.ForceSendFields, "RoleDescription")
+ }
+ hasUpdates = true
+ }
+
+ addNames := splitCSV(c.AddPrivileges)
+ removeNames := splitCSV(c.RemovePrivileges)
+ if len(addNames) > 0 || len(removeNames) > 0 {
+ var updatedPrivs []*admin.RoleRolePrivileges
+ updatedPrivs, err = updateRolePrivileges(ctx, svc, role.RolePrivileges, addNames, removeNames)
+ if err != nil {
+ return err
+ }
+ role.RolePrivileges = updatedPrivs
+ hasUpdates = true
+ }
+
+ if !hasUpdates {
+ return usage("no updates specified")
+ }
+
+ updated, err := svc.Roles.Update(adminCustomerID(), roleID, role).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update role %s: %w", c.Role, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Updated role: %s (%d)\n", updated.RoleName, updated.RoleId)
+ return nil
+}
+
+type RolesDeleteCmd struct {
+ Role string `arg:"" name:"role" help:"Role ID or name"`
+}
+
+func (c *RolesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete role %s", c.Role)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ roleID, _, err := resolveRole(ctx, svc, c.Role)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Roles.Delete(adminCustomerID(), roleID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete role %s: %w", c.Role, err)
+ }
+
+ u.Out().Printf("Deleted role: %s\n", c.Role)
+ return nil
+}
+
+type RolesPrivilegesCmd struct{}
+
+func (c *RolesPrivilegesCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Privileges.List(adminCustomerID()).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list privileges: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Items) == 0 {
+ u.Err().Println("No privileges found")
+ return nil
+ }
+
+ flat := flattenPrivileges(resp.Items)
+ sort.Slice(flat, func(i, j int) bool { return flat[i].PrivilegeName < flat[j].PrivilegeName })
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "PRIVILEGE\tSERVICE ID\tOU SCOPABLE")
+ for _, priv := range flat {
+ fmt.Fprintf(w, "%s\t%s\t%t\n",
+ sanitizeTab(priv.PrivilegeName),
+ sanitizeTab(priv.ServiceId),
+ priv.IsOuScopable,
+ )
+ }
+ return nil
+}
+
+func resolveRole(ctx context.Context, svc *admin.Service, role string) (string, *admin.Role, error) {
+ role = strings.TrimSpace(role)
+ if role == "" {
+ return "", nil, usage("role required")
+ }
+ if _, err := strconv.ParseInt(role, 10, 64); err == nil {
+ return role, nil, nil
+ }
+
+ roles, err := listAllRoles(ctx, svc)
+ if err != nil {
+ return "", nil, err
+ }
+ for _, r := range roles {
+ if r == nil {
+ continue
+ }
+ if strings.EqualFold(r.RoleName, role) {
+ return strconv.FormatInt(r.RoleId, 10), r, nil
+ }
+ }
+ return "", nil, fmt.Errorf("role %q not found", role)
+}
+
+func listAllRoles(ctx context.Context, svc *admin.Service) ([]*admin.Role, error) {
+ roles := make([]*admin.Role, 0)
+ call := svc.Roles.List(adminCustomerID()).MaxResults(200)
+ for {
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("list roles: %w", err)
+ }
+ roles = append(roles, resp.Items...)
+ if resp.NextPageToken == "" {
+ break
+ }
+ call = call.PageToken(resp.NextPageToken)
+ }
+ return roles, nil
+}
+
+func buildRolePrivileges(ctx context.Context, svc *admin.Service, names []string) ([]*admin.RoleRolePrivileges, error) {
+ privs, err := privilegeMap(ctx, svc)
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]*admin.RoleRolePrivileges, 0, len(names))
+ for _, name := range names {
+ key := strings.TrimSpace(name)
+ if key == "" {
+ continue
+ }
+ priv, ok := privs[strings.ToLower(key)]
+ if !ok {
+ return nil, fmt.Errorf("unknown privilege %q", key)
+ }
+ out = append(out, &admin.RoleRolePrivileges{PrivilegeName: priv.PrivilegeName, ServiceId: priv.ServiceId})
+ }
+ if len(out) == 0 {
+ return nil, usage("no privileges specified")
+ }
+ return out, nil
+}
+
+func updateRolePrivileges(ctx context.Context, svc *admin.Service, existing []*admin.RoleRolePrivileges, add, remove []string) ([]*admin.RoleRolePrivileges, error) {
+ set := make(map[string]*admin.RoleRolePrivileges)
+ for _, p := range existing {
+ if p == nil {
+ continue
+ }
+ set[strings.ToLower(p.PrivilegeName)] = p
+ }
+
+ if len(remove) > 0 {
+ for _, name := range remove {
+ key := strings.ToLower(strings.TrimSpace(name))
+ if key == "" {
+ continue
+ }
+ delete(set, key)
+ }
+ }
+
+ if len(add) > 0 {
+ privs, err := privilegeMap(ctx, svc)
+ if err != nil {
+ return nil, err
+ }
+ for _, name := range add {
+ key := strings.ToLower(strings.TrimSpace(name))
+ if key == "" {
+ continue
+ }
+ priv, ok := privs[key]
+ if !ok {
+ return nil, fmt.Errorf("unknown privilege %q", name)
+ }
+ set[key] = &admin.RoleRolePrivileges{PrivilegeName: priv.PrivilegeName, ServiceId: priv.ServiceId}
+ }
+ }
+
+ out := make([]*admin.RoleRolePrivileges, 0, len(set))
+ for _, p := range set {
+ out = append(out, p)
+ }
+ return out, nil
+}
+
+func privilegeMap(ctx context.Context, svc *admin.Service) (map[string]*admin.Privilege, error) {
+ resp, err := svc.Privileges.List(adminCustomerID()).Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("list privileges: %w", err)
+ }
+ flat := flattenPrivileges(resp.Items)
+ out := make(map[string]*admin.Privilege, len(flat))
+ for _, p := range flat {
+ if p == nil {
+ continue
+ }
+ out[strings.ToLower(p.PrivilegeName)] = p
+ }
+ return out, nil
+}
+
+func flattenPrivileges(items []*admin.Privilege) []*admin.Privilege {
+ var out []*admin.Privilege
+ var walk func(p *admin.Privilege)
+ walk = func(p *admin.Privilege) {
+ if p == nil {
+ return
+ }
+ out = append(out, p)
+ for _, child := range p.ChildPrivileges {
+ walk(child)
+ }
+ }
+ for _, p := range items {
+ walk(p)
+ }
+ return out
+}
diff --git a/internal/cmd/roles_test.go b/internal/cmd/roles_test.go
new file mode 100644
index 00000000..62939fe0
--- /dev/null
+++ b/internal/cmd/roles_test.go
@@ -0,0 +1,423 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestRolesListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/roles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "roleId": "123",
+ "roleName": "Helpdesk",
+ "isSystemRole": false,
+ "isSuperAdminRole": false,
+ "rolePrivileges": []map[string]any{{"privilegeName": "READ"}},
+ },
+ },
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &RolesListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Helpdesk") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesCreateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "privileges"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"privilegeName": "READ", "serviceId": "svc"},
+ {"privilegeName": "WRITE", "serviceId": "svc"},
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/roles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "roleId": "456",
+ "roleName": "Helpdesk",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &RolesCreateCmd{Name: "Helpdesk", Privileges: "READ,WRITE"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created role") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestAdminsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"roleId": "123", "roleName": "Helpdesk"},
+ },
+ })
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roleassignments"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "roleAssignmentId": "1",
+ "roleId": "123",
+ "assignedTo": "user-1",
+ "scopeType": "CUSTOMER",
+ },
+ },
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &AdminsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Helpdesk") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestAdminsCreateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"roleId": "123", "roleName": "Helpdesk"},
+ },
+ })
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/users/"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "user-1"})
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/roleassignments"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"roleAssignmentId": "99"})
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &AdminsCreateCmd{User: "sam@example.com", Role: "Helpdesk"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Assigned role") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestAdminsDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/roleassignments/"):
+ w.WriteHeader(http.StatusOK)
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &AdminsDeleteCmd{AssignmentID: "99"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted admin assignment") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles/"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "roleId": "123",
+ "roleName": "Super Admin",
+ "isSystemRole": true,
+ "isSuperAdminRole": true,
+ "roleDescription": "Full access",
+ "rolePrivileges": []map[string]any{
+ {"privilegeName": "ADMIN_READ"},
+ {"privilegeName": "ADMIN_WRITE"},
+ },
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &RolesGetCmd{Role: "123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Super Admin") || !strings.Contains(out, "Full access") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesUpdateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles/"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "roleId": "456",
+ "roleName": "Custom Role",
+ "rolePrivileges": []map[string]any{{"privilegeName": "READ", "serviceId": "svc"}},
+ })
+ return
+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/roles/"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "roleId": "456",
+ "roleName": "Updated Role",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated Role"
+ cmd := &RolesUpdateCmd{Role: "456", Name: &newName}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Updated role") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/roles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"roleId": "789", "roleName": "ToDelete"},
+ },
+ })
+ return
+ case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/roles/"):
+ w.WriteHeader(http.StatusOK)
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &RolesDeleteCmd{Role: "ToDelete"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted role") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesPrivilegesCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/privileges"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"privilegeName": "ADMIN_READ", "serviceId": "admin.directory", "isOuScopable": true},
+ {"privilegeName": "ADMIN_WRITE", "serviceId": "admin.directory", "isOuScopable": false},
+ },
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &RolesPrivilegesCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "ADMIN_READ") || !strings.Contains(out, "ADMIN_WRITE") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesUpdateCmd_AddRemovePrivileges(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/privileges"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"privilegeName": "READ", "serviceId": "svc"},
+ {"privilegeName": "WRITE", "serviceId": "svc"},
+ {"privilegeName": "DELETE", "serviceId": "svc"},
+ },
+ })
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles/"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "roleId": "456",
+ "roleName": "Custom Role",
+ "rolePrivileges": []map[string]any{
+ {"privilegeName": "READ", "serviceId": "svc"},
+ },
+ })
+ return
+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/roles/"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "roleId": "456",
+ "roleName": "Custom Role",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &RolesUpdateCmd{Role: "456", AddPrivileges: "WRITE", RemovePrivileges: "READ"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Updated role") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestRolesGetCmd_ByName(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/roles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {
+ "roleId": "999",
+ "roleName": "CustomRole",
+ "isSystemRole": false,
+ "isSuperAdminRole": false,
+ "roleDescription": "A custom role",
+ "rolePrivileges": []map[string]any{{"privilegeName": "SOME_PRIVILEGE"}},
+ },
+ },
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &RolesGetCmd{Role: "CustomRole"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "CustomRole") || !strings.Contains(out, "A custom role") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 28012deb..116c6029 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -21,11 +21,14 @@ import (
const (
colorAuto = "auto"
colorNever = "never"
+
+ strTrue = "true"
+ strFalse = "false"
)
type RootFlags struct {
Color string `help:"Color output: auto|always|never" default:"${color}"`
- Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets)"`
+ Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/admin)"`
Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"`
EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"`
JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}"`
@@ -40,25 +43,55 @@ type CLI struct {
Version kong.VersionFlag `help:"Print version and exit"`
- Auth AuthCmd `cmd:"" help:"Auth and credentials"`
- Groups GroupsCmd `cmd:"" help:"Google Groups"`
- Drive DriveCmd `cmd:"" help:"Google Drive"`
- Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
- Slides SlidesCmd `cmd:"" help:"Google Slides"`
- Calendar CalendarCmd `cmd:"" help:"Google Calendar"`
- Classroom ClassroomCmd `cmd:"" help:"Google Classroom"`
- Time TimeCmd `cmd:"" help:"Local time utilities"`
- Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"`
- Chat ChatCmd `cmd:"" help:"Google Chat"`
- Contacts ContactsCmd `cmd:"" help:"Google Contacts"`
- Tasks TasksCmd `cmd:"" help:"Google Tasks"`
- People PeopleCmd `cmd:"" help:"Google People"`
- Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
- Sheets SheetsCmd `cmd:"" help:"Google Sheets"`
- Config ConfigCmd `cmd:"" help:"Manage configuration"`
- VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"`
- Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"`
- Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"`
+ Auth AuthCmd `cmd:"" help:"Auth and credentials"`
+ Groups GroupsCmd `cmd:"" help:"Google Groups"`
+ Users UsersCmd `cmd:"" help:"Workspace users"`
+ Orgunits OrgunitsCmd `cmd:"" help:"Organizational units"`
+ Domains DomainsCmd `cmd:"" help:"Workspace domains"`
+ Aliases AliasesCmd `cmd:"" help:"Workspace aliases"`
+ Roles RolesCmd `cmd:"" help:"Admin roles"`
+ Admins AdminsCmd `cmd:"" help:"Admin assignments"`
+ Reports ReportsCmd `cmd:"" help:"Admin reports"`
+ Vault VaultCmd `cmd:"" help:"Google Vault"`
+ Alerts AlertsCmd `cmd:"" help:"Security alerts"`
+ SSO SSOCmd `cmd:"" name:"sso" help:"Inbound SSO"`
+ CAA CAACmd `cmd:"" name:"caa" help:"Context-aware access"`
+ Licenses LicensesCmd `cmd:"" help:"Workspace licenses"`
+ Resources ResourcesCmd `cmd:"" help:"Calendar resources"`
+ Schemas SchemasCmd `cmd:"" help:"Custom user schemas"`
+ Transfer TransferCmd `cmd:"" name:"transfer" help:"Data transfer"`
+ Printers PrintersCmd `cmd:"" help:"Chrome printers"`
+ Forms FormsCmd `cmd:"" help:"Google Forms"`
+ Sites SitesCmd `cmd:"" help:"Google Sites"`
+ YouTube YouTubeCmd `cmd:"" help:"YouTube"`
+ Looker LookerStudioCmd `cmd:"" name:"lookerstudio" help:"Looker Studio"`
+ Meet MeetCmd `cmd:"" help:"Google Meet"`
+ Analytics AnalyticsCmd `cmd:"" help:"Analytics Admin"`
+ Labels LabelsCmd `cmd:"" help:"Drive Labels"`
+ CI CloudIdentityCmd `cmd:"" name:"ci" help:"Cloud Identity"`
+ Reseller ResellerCmd `cmd:"" help:"Reseller API"`
+ Channel ChannelCmd `cmd:"" help:"Cloud Channel"`
+ Projects ProjectsCmd `cmd:"" help:"GCP projects"`
+ ServiceAccounts ServiceAccountsCmd `cmd:"" name:"serviceaccounts" help:"Service accounts"`
+ Drive DriveCmd `cmd:"" help:"Google Drive"`
+ Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
+ Slides SlidesCmd `cmd:"" help:"Google Slides"`
+ Calendar CalendarCmd `cmd:"" help:"Google Calendar"`
+ Classroom ClassroomCmd `cmd:"" help:"Google Classroom"`
+ Time TimeCmd `cmd:"" help:"Local time utilities"`
+ Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"`
+ Chat ChatCmd `cmd:"" help:"Google Chat"`
+ Contacts ContactsCmd `cmd:"" help:"Google Contacts"`
+ Tasks TasksCmd `cmd:"" help:"Google Tasks"`
+ People PeopleCmd `cmd:"" help:"Google People"`
+ Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
+ Sheets SheetsCmd `cmd:"" help:"Google Sheets"`
+ Config ConfigCmd `cmd:"" help:"Manage configuration"`
+ CSV CSVCmd `cmd:"" name:"csv" help:"Process commands from CSV files"`
+ Batch BatchCmd `cmd:"" help:"Run commands from a file"`
+ VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"`
+ Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"`
+ Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"`
}
type exitPanic struct{ code int }
@@ -163,9 +196,9 @@ func envOr(key, fallback string) string {
func boolString(v bool) string {
if v {
- return "true"
+ return strTrue
}
- return "false"
+ return strFalse
}
func newParser(description string) (*kong.Kong, *CLI, error) {
diff --git a/internal/cmd/schemas.go b/internal/cmd/schemas.go
new file mode 100644
index 00000000..8526dd22
--- /dev/null
+++ b/internal/cmd/schemas.go
@@ -0,0 +1,348 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type SchemasCmd struct {
+ List SchemasListCmd `cmd:"" name:"list" aliases:"ls" help:"List schemas"`
+ Get SchemasGetCmd `cmd:"" name:"get" help:"Get schema"`
+ Create SchemasCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create schema"`
+ Update SchemasUpdateCmd `cmd:"" name:"update" help:"Update schema"`
+ Delete SchemasDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete schema"`
+}
+
+type SchemasListCmd struct{}
+
+func (c *SchemasListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Schemas.List(adminCustomerID()).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list schemas: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Schemas) == 0 {
+ u.Err().Println("No schemas found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "NAME\tFIELDS\tID")
+ for _, schema := range resp.Schemas {
+ if schema == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%d\t%s\n",
+ sanitizeTab(schema.SchemaName),
+ len(schema.Fields),
+ sanitizeTab(schema.SchemaId),
+ )
+ }
+ return nil
+}
+
+type SchemasGetCmd struct {
+ Name string `arg:"" name:"name" help:"Schema name or ID"`
+}
+
+func (c *SchemasGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("schema name is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ schema, err := svc.Schemas.Get(adminCustomerID(), name).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get schema %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, schema)
+ }
+
+ u.Out().Printf("Name: %s\n", schema.SchemaName)
+ u.Out().Printf("ID: %s\n", schema.SchemaId)
+ if schema.DisplayName != "" {
+ u.Out().Printf("Display: %s\n", schema.DisplayName)
+ }
+ if len(schema.Fields) > 0 {
+ u.Out().Printf("Fields: %d\n", len(schema.Fields))
+ for _, field := range schema.Fields {
+ if field == nil {
+ continue
+ }
+ u.Out().Printf("- %s (%s)\n", field.FieldName, field.FieldType)
+ }
+ }
+ return nil
+}
+
+type SchemasCreateCmd struct {
+ Name string `arg:"" name:"name" help:"Schema name"`
+ Fields []string `name:"field" help:"Field spec NAME:TYPE (repeatable)"`
+}
+
+func (c *SchemasCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("schema name is required")
+ }
+
+ fields, err := parseSchemaFields(c.Fields)
+ if err != nil {
+ return err
+ }
+ if len(fields) == 0 {
+ return usage("--field is required")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ schema := &admin.Schema{
+ SchemaName: name,
+ DisplayName: name,
+ Fields: fields,
+ }
+
+ created, err := svc.Schemas.Insert(adminCustomerID(), schema).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create schema %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created schema: %s (%s)\n", created.SchemaName, created.SchemaId)
+ return nil
+}
+
+type SchemasUpdateCmd struct {
+ Name string `arg:"" name:"name" help:"Schema name or ID"`
+ AddFields []string `name:"add-field" help:"Field spec NAME:TYPE (repeatable)"`
+ RemoveField []string `name:"remove-field" help:"Field name to remove (repeatable)"`
+}
+
+func (c *SchemasUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("schema name is required")
+ }
+
+ if len(c.AddFields) == 0 && len(c.RemoveField) == 0 {
+ return usage("no updates specified")
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ schema, err := svc.Schemas.Get(adminCustomerID(), name).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get schema %s: %w", name, err)
+ }
+
+ fieldMap := make(map[string]*admin.SchemaFieldSpec, len(schema.Fields))
+ order := make([]string, 0, len(schema.Fields))
+ for _, field := range schema.Fields {
+ if field == nil {
+ continue
+ }
+ fieldMap[field.FieldName] = field
+ order = append(order, field.FieldName)
+ }
+
+ newFields, err := parseSchemaFields(c.AddFields)
+ if err != nil {
+ return err
+ }
+ for _, field := range newFields {
+ if fieldMap[field.FieldName] != nil {
+ return fmt.Errorf("field %s already exists", field.FieldName)
+ }
+ fieldMap[field.FieldName] = field
+ order = append(order, field.FieldName)
+ }
+
+ for _, remove := range c.RemoveField {
+ remove = strings.TrimSpace(remove)
+ if remove == "" {
+ continue
+ }
+ if _, ok := fieldMap[remove]; !ok {
+ return fmt.Errorf("field %s not found", remove)
+ }
+ delete(fieldMap, remove)
+ }
+
+ updatedFields := make([]*admin.SchemaFieldSpec, 0, len(fieldMap))
+ seen := make(map[string]struct{}, len(fieldMap))
+ for _, name := range order {
+ if field, ok := fieldMap[name]; ok {
+ updatedFields = append(updatedFields, field)
+ seen[name] = struct{}{}
+ }
+ }
+ if len(seen) != len(fieldMap) {
+ remaining := make([]string, 0, len(fieldMap)-len(seen))
+ for key := range fieldMap {
+ if _, ok := seen[key]; !ok {
+ remaining = append(remaining, key)
+ }
+ }
+ sort.Strings(remaining)
+ for _, key := range remaining {
+ updatedFields = append(updatedFields, fieldMap[key])
+ }
+ }
+
+ schema.Fields = updatedFields
+ updated, err := svc.Schemas.Update(adminCustomerID(), name, schema).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update schema %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated schema: %s\n", updated.SchemaName)
+ return nil
+}
+
+type SchemasDeleteCmd struct {
+ Name string `arg:"" name:"name" help:"Schema name or ID"`
+}
+
+func (c *SchemasDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ return usage("schema name is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete schema %s", name)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Schemas.Delete(adminCustomerID(), name).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete schema %s: %w", name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"schema": name, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted schema: %s\n", name)
+ return nil
+}
+
+func parseSchemaFields(fields []string) ([]*admin.SchemaFieldSpec, error) {
+ out := make([]*admin.SchemaFieldSpec, 0, len(fields))
+ for _, spec := range fields {
+ spec = strings.TrimSpace(spec)
+ if spec == "" {
+ continue
+ }
+ parts := strings.SplitN(spec, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid field spec %q (expected NAME:TYPE)", spec)
+ }
+ name := strings.TrimSpace(parts[0])
+ if name == "" {
+ return nil, fmt.Errorf("invalid field spec %q (empty name)", spec)
+ }
+ fieldType, ok := normalizeSchemaFieldType(parts[1])
+ if !ok {
+ return nil, fmt.Errorf("invalid field type %q", parts[1])
+ }
+ out = append(out, &admin.SchemaFieldSpec{
+ FieldName: name,
+ FieldType: fieldType,
+ DisplayName: name,
+ })
+ }
+ return out, nil
+}
+
+func normalizeSchemaFieldType(raw string) (string, bool) {
+ switch strings.ToLower(strings.TrimSpace(raw)) {
+ case "bool", "boolean":
+ return "BOOL", true
+ case "date":
+ return "DATE", true
+ case "double":
+ return "DOUBLE", true
+ case "email":
+ return "EMAIL", true
+ case "int", "int64":
+ return "INT64", true
+ case "phone":
+ return "PHONE", true
+ case "string", "str":
+ return "STRING", true
+ default:
+ return "", false
+ }
+}
diff --git a/internal/cmd/schemas_test.go b/internal/cmd/schemas_test.go
new file mode 100644
index 00000000..82ecbbd4
--- /dev/null
+++ b/internal/cmd/schemas_test.go
@@ -0,0 +1,863 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func TestSchemasListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/schemas") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemas": []map[string]any{
+ {"schemaName": "Custom", "schemaId": "s1", "fields": []map[string]any{{"fieldName": "Department"}}},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Custom") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSchemasListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemas": []map[string]any{
+ {"schemaName": "Custom", "schemaId": "s1"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"schemas"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestSchemasListCmd_MultipleSchemas(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemas": []map[string]any{
+ {"schemaName": "Schema1", "schemaId": "s1", "fields": []map[string]any{{"fieldName": "f1"}, {"fieldName": "f2"}}},
+ {"schemaName": "Schema2", "schemaId": "s2", "fields": []map[string]any{{"fieldName": "f3"}}},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Schema1") || !strings.Contains(out, "Schema2") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ // Schema1 has 2 fields
+ if !strings.Contains(out, "2") {
+ t.Fatalf("expected field count, got: %s", out)
+ }
+}
+
+func TestSchemasGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/schemas/Custom") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "displayName": "Custom Schema",
+ "fields": []map[string]any{
+ {"fieldName": "Department", "fieldType": "STRING"},
+ {"fieldName": "EmployeeID", "fieldType": "INT64"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasGetCmd{Name: "Custom"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Name:") || !strings.Contains(out, "Custom") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "ID:") || !strings.Contains(out, "s1") {
+ t.Fatalf("expected ID, got: %s", out)
+ }
+ if !strings.Contains(out, "Display:") || !strings.Contains(out, "Custom Schema") {
+ t.Fatalf("expected display name, got: %s", out)
+ }
+ if !strings.Contains(out, "Department") || !strings.Contains(out, "STRING") {
+ t.Fatalf("expected fields, got: %s", out)
+ }
+}
+
+func TestSchemasGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasGetCmd{Name: "Custom"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"schemaName"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestSchemasGetCmd_MissingName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasGetCmd{Name: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing name")
+ }
+ if !strings.Contains(err.Error(), "schema name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasGetCmd_NoDisplayName(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "fields": []map[string]any{},
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasGetCmd{Name: "Custom"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should not contain "Display:" when displayName is empty
+ if strings.Contains(out, "Display:") {
+ t.Fatalf("expected no display line when empty, got: %s", out)
+ }
+}
+
+func TestSchemasCreateCmd(t *testing.T) {
+ var gotName string
+ var gotType string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/schemas"):
+ var payload struct {
+ SchemaName string `json:"schemaName"`
+ Fields []struct {
+ FieldName string `json:"fieldName"`
+ FieldType string `json:"fieldType"`
+ } `json:"fields"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotName = payload.SchemaName
+ if len(payload.Fields) > 0 {
+ gotType = payload.Fields[0].FieldType
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": payload.SchemaName,
+ "schemaId": "s1",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{"Department:string"}}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotName != "Custom" {
+ t.Fatalf("unexpected name: %q", gotName)
+ }
+ if gotType != "STRING" {
+ t.Fatalf("unexpected type: %q", gotType)
+ }
+ if !strings.Contains(out, "Created schema") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSchemasCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{"Department:string"}}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"schemaName"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestSchemasCreateCmd_MissingName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasCreateCmd{Name: "", Fields: []string{"Department:string"}}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing name")
+ }
+ if !strings.Contains(err.Error(), "schema name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasCreateCmd_MissingFields(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{}}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing fields")
+ }
+ if !strings.Contains(err.Error(), "--field is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasCreateCmd_MultipleFields(t *testing.T) {
+ var gotFields []string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ var payload struct {
+ Fields []struct {
+ FieldName string `json:"fieldName"`
+ FieldType string `json:"fieldType"`
+ } `json:"fields"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ for _, f := range payload.Fields {
+ gotFields = append(gotFields, f.FieldName+":"+f.FieldType)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{"Department:string", "EmployeeID:int", "Active:bool"}}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if len(gotFields) != 3 {
+ t.Fatalf("expected 3 fields, got: %v", gotFields)
+ }
+ if gotFields[0] != "Department:STRING" {
+ t.Errorf("unexpected field: %s", gotFields[0])
+ }
+ if gotFields[1] != "EmployeeID:INT64" {
+ t.Errorf("unexpected field: %s", gotFields[1])
+ }
+ if gotFields[2] != "Active:BOOL" {
+ t.Errorf("unexpected field: %s", gotFields[2])
+ }
+}
+
+func TestSchemasUpdateCmd(t *testing.T) {
+ calls := 0
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/schemas/Custom"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "fields": []map[string]any{
+ {"fieldName": "Department", "fieldType": "STRING"},
+ },
+ })
+ return
+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/schemas/Custom"):
+ calls++
+ var payload struct {
+ Fields []struct {
+ FieldName string `json:"fieldName"`
+ FieldType string `json:"fieldType"`
+ } `json:"fields"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: "Custom", AddFields: []string{"Title:string"}}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if calls != 1 {
+ t.Fatalf("expected 1 update call, got %d", calls)
+ }
+ if !strings.Contains(out, "Updated schema") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSchemasUpdateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodGet {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "fields": []map[string]any{{"fieldName": "Department", "fieldType": "STRING"}},
+ })
+ return
+ }
+ if r.Method == http.MethodPut {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: "Custom", AddFields: []string{"Title:string"}}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"schemaName"`) {
+ t.Fatalf("expected JSON output, got: %s", out)
+ }
+}
+
+func TestSchemasUpdateCmd_MissingName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: " ", AddFields: []string{"Title:string"}}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing name")
+ }
+ if !strings.Contains(err.Error(), "schema name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasUpdateCmd_NoUpdates(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: "Custom"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no updates")
+ }
+ if !strings.Contains(err.Error(), "no updates specified") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasUpdateCmd_RemoveField(t *testing.T) {
+ var updatedFields []string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodGet {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "fields": []map[string]any{
+ {"fieldName": "Department", "fieldType": "STRING"},
+ {"fieldName": "Title", "fieldType": "STRING"},
+ },
+ })
+ return
+ }
+ if r.Method == http.MethodPut {
+ var payload struct {
+ Fields []struct {
+ FieldName string `json:"fieldName"`
+ } `json:"fields"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ for _, f := range payload.Fields {
+ updatedFields = append(updatedFields, f.FieldName)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: "Custom", RemoveField: []string{"Title"}}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if len(updatedFields) != 1 || updatedFields[0] != "Department" {
+ t.Fatalf("expected only Department field, got: %v", updatedFields)
+ }
+}
+
+func TestSchemasUpdateCmd_FieldAlreadyExists(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "fields": []map[string]any{
+ {"fieldName": "Department", "fieldType": "STRING"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: "Custom", AddFields: []string{"Department:int"}}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for duplicate field")
+ }
+ if !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasUpdateCmd_FieldNotFound(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "schemaName": "Custom",
+ "schemaId": "s1",
+ "fields": []map[string]any{
+ {"fieldName": "Department", "fieldType": "STRING"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SchemasUpdateCmd{Name: "Custom", RemoveField: []string{"NonExistent"}}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for non-existent field")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasDeleteCmd(t *testing.T) {
+ var deleteCalled bool
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/schemas/Custom") {
+ deleteCalled = true
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SchemasDeleteCmd{Name: "Custom"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !deleteCalled {
+ t.Fatal("delete was not called")
+ }
+ if !strings.Contains(out, "Deleted schema") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSchemasDeleteCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SchemasDeleteCmd{Name: "Custom"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"deleted"`) || !strings.Contains(out, `true`) {
+ t.Fatalf("expected JSON output with deleted: true, got: %s", out)
+ }
+}
+
+func TestSchemasDeleteCmd_MissingName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SchemasDeleteCmd{Name: ""}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing name")
+ }
+ if !strings.Contains(err.Error(), "schema name is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSchemasDeleteCmd_RequiresConfirmation(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: false, NoInput: true}
+ cmd := &SchemasDeleteCmd{Name: "Custom"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error when confirmation is required but not provided")
+ }
+ if !strings.Contains(err.Error(), "refusing") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestParseSchemaFields(t *testing.T) {
+ tests := []struct {
+ name string
+ input []string
+ wantLen int
+ wantName string
+ wantType string
+ wantErr bool
+ }{
+ {
+ name: "string type",
+ input: []string{"Department:string"},
+ wantLen: 1,
+ wantName: "Department",
+ wantType: "STRING",
+ },
+ {
+ name: "str alias",
+ input: []string{"Name:str"},
+ wantLen: 1,
+ wantName: "Name",
+ wantType: "STRING",
+ },
+ {
+ name: "int type",
+ input: []string{"Count:int"},
+ wantLen: 1,
+ wantName: "Count",
+ wantType: "INT64",
+ },
+ {
+ name: "int64 type",
+ input: []string{"Count:int64"},
+ wantLen: 1,
+ wantName: "Count",
+ wantType: "INT64",
+ },
+ {
+ name: "bool type",
+ input: []string{"Active:bool"},
+ wantLen: 1,
+ wantName: "Active",
+ wantType: "BOOL",
+ },
+ {
+ name: "boolean type",
+ input: []string{"Active:boolean"},
+ wantLen: 1,
+ wantName: "Active",
+ wantType: "BOOL",
+ },
+ {
+ name: "date type",
+ input: []string{"StartDate:date"},
+ wantLen: 1,
+ wantName: "StartDate",
+ wantType: "DATE",
+ },
+ {
+ name: "double type",
+ input: []string{"Salary:double"},
+ wantLen: 1,
+ wantName: "Salary",
+ wantType: "DOUBLE",
+ },
+ {
+ name: "email type",
+ input: []string{"Contact:email"},
+ wantLen: 1,
+ wantName: "Contact",
+ wantType: "EMAIL",
+ },
+ {
+ name: "phone type",
+ input: []string{"Mobile:phone"},
+ wantLen: 1,
+ wantName: "Mobile",
+ wantType: "PHONE",
+ },
+ {
+ name: "multiple fields",
+ input: []string{"Name:string", "Age:int", "Active:bool"},
+ wantLen: 3,
+ },
+ {
+ name: "empty input",
+ input: []string{},
+ wantLen: 0,
+ },
+ {
+ name: "whitespace only",
+ input: []string{" ", "\t"},
+ wantLen: 0,
+ },
+ {
+ name: "invalid format - no colon",
+ input: []string{"Department"},
+ wantErr: true,
+ },
+ {
+ name: "invalid type",
+ input: []string{"Department:invalid"},
+ wantErr: true,
+ },
+ {
+ name: "empty name",
+ input: []string{":string"},
+ wantErr: true,
+ },
+ {
+ name: "case insensitive type",
+ input: []string{"Field:STRING"},
+ wantLen: 1,
+ wantName: "Field",
+ wantType: "STRING",
+ },
+ {
+ name: "type with spaces",
+ input: []string{"Field: string "},
+ wantLen: 1,
+ wantName: "Field",
+ wantType: "STRING",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseSchemaFields(tt.input)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got) != tt.wantLen {
+ t.Fatalf("expected %d fields, got %d", tt.wantLen, len(got))
+ }
+ if tt.wantLen > 0 && tt.wantName != "" {
+ if got[0].FieldName != tt.wantName {
+ t.Errorf("expected name %q, got %q", tt.wantName, got[0].FieldName)
+ }
+ if got[0].FieldType != tt.wantType {
+ t.Errorf("expected type %q, got %q", tt.wantType, got[0].FieldType)
+ }
+ }
+ })
+ }
+}
+
+func TestNormalizeSchemaFieldType(t *testing.T) {
+ tests := []struct {
+ input string
+ wantType string
+ wantOK bool
+ }{
+ {"bool", "BOOL", true},
+ {"boolean", "BOOL", true},
+ {"BOOL", "BOOL", true},
+ {"date", "DATE", true},
+ {"DATE", "DATE", true},
+ {"double", "DOUBLE", true},
+ {"DOUBLE", "DOUBLE", true},
+ {"email", "EMAIL", true},
+ {"EMAIL", "EMAIL", true},
+ {"int", "INT64", true},
+ {"int64", "INT64", true},
+ {"INT64", "INT64", true},
+ {"phone", "PHONE", true},
+ {"PHONE", "PHONE", true},
+ {"string", "STRING", true},
+ {"str", "STRING", true},
+ {"STRING", "STRING", true},
+ {" string ", "STRING", true},
+ {"invalid", "", false},
+ {"", "", false},
+ {"text", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ gotType, gotOK := normalizeSchemaFieldType(tt.input)
+ if gotOK != tt.wantOK {
+ t.Errorf("normalizeSchemaFieldType(%q) ok = %v, want %v", tt.input, gotOK, tt.wantOK)
+ }
+ if gotType != tt.wantType {
+ t.Errorf("normalizeSchemaFieldType(%q) = %q, want %q", tt.input, gotType, tt.wantType)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/serviceaccounts.go b/internal/cmd/serviceaccounts.go
new file mode 100644
index 00000000..70465a56
--- /dev/null
+++ b/internal/cmd/serviceaccounts.go
@@ -0,0 +1,364 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "google.golang.org/api/iam/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newIAMService = googleapi.NewIAM
+
+type ServiceAccountsCmd struct {
+ List ServiceAccountsListCmd `cmd:"" name:"list" aliases:"ls" help:"List service accounts"`
+ Create ServiceAccountsCreateCmd `cmd:"" name:"create" help:"Create a service account"`
+ Delete ServiceAccountsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a service account"`
+ Keys ServiceAccountsKeysCmd `cmd:"" name:"keys" help:"Manage service account keys"`
+}
+
+type ServiceAccountsListCmd struct {
+ Project string `name:"project" help:"GCP project ID" required:""`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *ServiceAccountsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ project := strings.TrimSpace(c.Project)
+ if project == "" {
+ return usage("--project is required")
+ }
+
+ svc, err := newIAMService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ parent := "projects/" + project
+ call := svc.Projects.ServiceAccounts.List(parent).PageSize(c.Max)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list service accounts: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Accounts) == 0 {
+ u.Err().Println("No service accounts found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "EMAIL\tNAME\tRESOURCE")
+ for _, sa := range resp.Accounts {
+ if sa == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(sa.Email),
+ sanitizeTab(sa.DisplayName),
+ sanitizeTab(sa.Name),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type ServiceAccountsCreateCmd struct {
+ Project string `name:"project" help:"GCP project ID" required:""`
+ Name string `name:"name" help:"Service account ID (short name)" required:""`
+ DisplayName string `name:"display-name" help:"Display name"`
+}
+
+func (c *ServiceAccountsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ project := strings.TrimSpace(c.Project)
+ accountID := strings.TrimSpace(c.Name)
+ if project == "" || accountID == "" {
+ return usage("--project and --name are required")
+ }
+ if !isValidServiceAccountID(accountID) {
+ return usage("--name must be a valid service account ID (lowercase letters, digits, hyphens)")
+ }
+
+ display := strings.TrimSpace(c.DisplayName)
+ if display == "" {
+ display = accountID
+ }
+
+ svc, err := newIAMService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ parent := "projects/" + project
+ req := &iam.CreateServiceAccountRequest{
+ AccountId: accountID,
+ ServiceAccount: &iam.ServiceAccount{
+ DisplayName: display,
+ },
+ }
+
+ resp, err := svc.Projects.ServiceAccounts.Create(parent, req).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create service account: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ u.Out().Printf("Created service account %s\n", resp.Email)
+ return nil
+}
+
+type ServiceAccountsDeleteCmd struct {
+ ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"`
+}
+
+func (c *ServiceAccountsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ sa := strings.TrimSpace(c.ServiceAccount)
+ if sa == "" {
+ return usage("service account is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete service account %s", sa)); err != nil {
+ return err
+ }
+
+ svc, err := newIAMService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ name := normalizeServiceAccountName(sa)
+ if _, err := svc.Projects.ServiceAccounts.Delete(name).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete service account %s: %w", sa, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "serviceAccount": sa})
+ }
+
+ u.Out().Printf("Deleted service account %s\n", sa)
+ return nil
+}
+
+type ServiceAccountsKeysCmd struct {
+ List ServiceAccountsKeysListCmd `cmd:"" name:"list" aliases:"ls" help:"List service account keys"`
+ Create ServiceAccountsKeysCreateCmd `cmd:"" name:"create" help:"Create a service account key"`
+ Delete ServiceAccountsKeysDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a service account key"`
+}
+
+type ServiceAccountsKeysListCmd struct {
+ ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"`
+}
+
+func (c *ServiceAccountsKeysListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ sa := strings.TrimSpace(c.ServiceAccount)
+ if sa == "" {
+ return usage("service account is required")
+ }
+
+ svc, err := newIAMService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ name := normalizeServiceAccountName(sa)
+ resp, err := svc.Projects.ServiceAccounts.Keys.List(name).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list keys: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Keys) == 0 {
+ u.Err().Println("No keys found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "KEY\tTYPE\tCREATED\tEXPIRES")
+ for _, key := range resp.Keys {
+ if key == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(key.Name),
+ sanitizeTab(key.KeyType),
+ sanitizeTab(key.ValidAfterTime),
+ sanitizeTab(key.ValidBeforeTime),
+ )
+ }
+ return nil
+}
+
+type ServiceAccountsKeysCreateCmd struct {
+ ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"`
+ Output string `name:"output" help:"Output file path" required:""`
+}
+
+func (c *ServiceAccountsKeysCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ sa := strings.TrimSpace(c.ServiceAccount)
+ if sa == "" {
+ return usage("service account is required")
+ }
+ output := strings.TrimSpace(c.Output)
+ if output == "" {
+ return usage("--output is required")
+ }
+
+ svc, err := newIAMService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ name := normalizeServiceAccountName(sa)
+ resp, err := svc.Projects.ServiceAccounts.Keys.Create(name, &iam.CreateServiceAccountKeyRequest{
+ PrivateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
+ }).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create key: %w", err)
+ }
+
+ payload, err := base64.StdEncoding.DecodeString(resp.PrivateKeyData)
+ if err != nil {
+ return fmt.Errorf("decode key data: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(output), 0o750); err != nil {
+ return fmt.Errorf("create output dir: %w", err)
+ }
+ if err := os.WriteFile(output, payload, 0o600); err != nil {
+ return fmt.Errorf("write key file: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"key": resp.Name, "output": output})
+ }
+
+ u.Out().Printf("Created key %s\n", resp.Name)
+ u.Out().Printf("Wrote credentials to %s\n", output)
+ return nil
+}
+
+type ServiceAccountsKeysDeleteCmd struct {
+ ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"`
+ KeyID string `arg:"" name:"key" help:"Key ID or resource name"`
+}
+
+func (c *ServiceAccountsKeysDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ sa := strings.TrimSpace(c.ServiceAccount)
+ key := strings.TrimSpace(c.KeyID)
+ if sa == "" || key == "" {
+ return usage("service account and key are required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete key %s", key)); err != nil {
+ return err
+ }
+
+ svc, err := newIAMService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ keyName := normalizeServiceAccountKeyName(sa, key)
+ if _, err := svc.Projects.ServiceAccounts.Keys.Delete(keyName).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete key %s: %w", key, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "key": key})
+ }
+
+ u.Out().Printf("Deleted key %s\n", key)
+ return nil
+}
+
+func normalizeServiceAccountName(sa string) string {
+ trimmed := strings.TrimSpace(sa)
+ if strings.HasPrefix(trimmed, "projects/") {
+ return trimmed
+ }
+ return "projects/-/serviceAccounts/" + trimmed
+}
+
+func normalizeServiceAccountKeyName(sa, key string) string {
+ trimmed := strings.TrimSpace(key)
+ if strings.HasPrefix(trimmed, "projects/") {
+ return trimmed
+ }
+ return fmt.Sprintf("%s/keys/%s", normalizeServiceAccountName(sa), trimmed)
+}
+
+func isValidServiceAccountID(id string) bool {
+ if id == "" {
+ return false
+ }
+ for i, r := range id {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case r >= '0' && r <= '9':
+ case r == '-':
+ default:
+ return false
+ }
+ if i == 0 && (r < 'a' || r > 'z') {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/cmd/serviceaccounts_test.go b/internal/cmd/serviceaccounts_test.go
new file mode 100644
index 00000000..4ef56c50
--- /dev/null
+++ b/internal/cmd/serviceaccounts_test.go
@@ -0,0 +1,1176 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/iam/v1"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+func newIAMServiceStub(t *testing.T, handler http.HandlerFunc) (*iam.Service, func()) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ svc, err := iam.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ srv.Close()
+ t.Fatalf("NewService: %v", err)
+ }
+ return svc, srv.Close
+}
+
+func stubIAMService(t *testing.T, svc *iam.Service) {
+ t.Helper()
+ orig := newIAMService
+ t.Cleanup(func() { newIAMService = orig })
+ newIAMService = func(context.Context, string) (*iam.Service, error) { return svc, nil }
+}
+
+// ============================================================================
+// ServiceAccountsListCmd Tests
+// ============================================================================
+
+func TestServiceAccountsListCmd_Success(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/serviceAccounts") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{
+ {
+ "email": "sa1@test-project.iam.gserviceaccount.com",
+ "displayName": "Service Account One",
+ "name": "projects/test-project/serviceAccounts/sa1@test-project.iam.gserviceaccount.com",
+ },
+ {
+ "email": "sa2@test-project.iam.gserviceaccount.com",
+ "displayName": "Service Account Two",
+ "name": "projects/test-project/serviceAccounts/sa2@test-project.iam.gserviceaccount.com",
+ },
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: "test-project", Max: 100}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "sa1@test-project.iam.gserviceaccount.com") {
+ t.Fatalf("expected email in output, got: %s", out)
+ }
+ if !strings.Contains(out, "Service Account One") {
+ t.Fatalf("expected display name in output, got: %s", out)
+ }
+}
+
+func TestServiceAccountsListCmd_JSON(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/serviceAccounts") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{
+ {
+ "email": "sa1@test-project.iam.gserviceaccount.com",
+ "displayName": "Service Account One",
+ },
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: "test-project"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "accounts") {
+ t.Fatalf("expected JSON accounts output, got: %s", out)
+ }
+}
+
+func TestServiceAccountsListCmd_EmptyResults(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: "test-project"}
+
+ // Should not error even with no accounts
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestServiceAccountsListCmd_EmptyProject(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty project")
+ }
+ if !strings.Contains(err.Error(), "--project is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsListCmd_NoAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &ServiceAccountsListCmd{Project: "test-project"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestServiceAccountsListCmd_APIError(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "permission denied", http.StatusForbidden)
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: "test-project"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "list service accounts") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsListCmd_Pagination(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ pageToken := r.URL.Query().Get("pageToken")
+ w.Header().Set("Content-Type", "application/json")
+ if pageToken == "page2" {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{
+ {"email": "sa2@test-project.iam.gserviceaccount.com"},
+ },
+ })
+ } else {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{
+ {"email": "sa1@test-project.iam.gserviceaccount.com"},
+ },
+ "nextPageToken": "page2",
+ })
+ }
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: "test-project", Page: "page2"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "sa2@test-project.iam.gserviceaccount.com") {
+ t.Fatalf("expected page 2 results, got: %s", out)
+ }
+}
+
+// ============================================================================
+// ServiceAccountsCreateCmd Tests
+// ============================================================================
+
+func TestServiceAccountsCreateCmd_Success(t *testing.T) {
+ var gotAccountID, gotDisplayName string
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/serviceAccounts") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload iam.CreateServiceAccountRequest
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotAccountID = payload.AccountId
+ if payload.ServiceAccount != nil {
+ gotDisplayName = payload.ServiceAccount.DisplayName
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "email": gotAccountID + "@test-project.iam.gserviceaccount.com",
+ "displayName": gotDisplayName,
+ "name": "projects/test-project/serviceAccounts/" + gotAccountID + "@test-project.iam.gserviceaccount.com",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{
+ Project: "test-project",
+ Name: "my-service-account",
+ DisplayName: "My Service Account",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotAccountID != "my-service-account" {
+ t.Fatalf("expected accountId 'my-service-account', got: %s", gotAccountID)
+ }
+ if gotDisplayName != "My Service Account" {
+ t.Fatalf("expected displayName 'My Service Account', got: %s", gotDisplayName)
+ }
+ if !strings.Contains(out, "Created service account") {
+ t.Fatalf("expected success message, got: %s", out)
+ }
+}
+
+func TestServiceAccountsCreateCmd_DefaultDisplayName(t *testing.T) {
+ var gotDisplayName string
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var payload iam.CreateServiceAccountRequest
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ if payload.ServiceAccount != nil {
+ gotDisplayName = payload.ServiceAccount.DisplayName
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "email": "my-sa@test-project.iam.gserviceaccount.com",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{
+ Project: "test-project",
+ Name: "my-sa",
+ // No DisplayName - should default to Name
+ }
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotDisplayName != "my-sa" {
+ t.Fatalf("expected displayName to default to accountId 'my-sa', got: %s", gotDisplayName)
+ }
+}
+
+func TestServiceAccountsCreateCmd_JSON(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "email": "my-sa@test-project.iam.gserviceaccount.com",
+ "displayName": "My SA",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{
+ Project: "test-project",
+ Name: "my-sa",
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "email") {
+ t.Fatalf("expected JSON output with email, got: %s", out)
+ }
+}
+
+func TestServiceAccountsCreateCmd_EmptyProject(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{Project: "", Name: "my-sa"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty project")
+ }
+ if !strings.Contains(err.Error(), "--project and --name are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsCreateCmd_EmptyName(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty name")
+ }
+ if !strings.Contains(err.Error(), "--project and --name are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsCreateCmd_InvalidName(t *testing.T) {
+ tests := []struct {
+ name string
+ accountName string
+ }{
+ {"uppercase", "MyServiceAccount"},
+ {"starts with digit", "1my-service-account"},
+ {"starts with hyphen", "-my-service-account"},
+ {"contains underscore", "my_service_account"},
+ {"contains special chars", "my-sa@test"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: tc.accountName}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatalf("expected error for invalid name: %s", tc.accountName)
+ }
+ if !strings.Contains(err.Error(), "valid service account ID") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+func TestServiceAccountsCreateCmd_ValidNames(t *testing.T) {
+ tests := []struct {
+ name string
+ accountName string
+ }{
+ {"simple", "my-sa"},
+ {"with digits", "my-sa-123"},
+ {"all lowercase", "myserviceaccount"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "email": tc.accountName + "@test-project.iam.gserviceaccount.com",
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: tc.accountName}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("expected no error for valid name %s, got: %v", tc.accountName, err)
+ }
+ })
+ }
+}
+
+func TestServiceAccountsCreateCmd_APIError(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "already exists", http.StatusConflict)
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsCreateCmd{
+ Project: "test-project",
+ Name: "my-sa",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "create service account") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsCreateCmd_NoAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: "my-sa"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+// ============================================================================
+// ServiceAccountsDeleteCmd Tests
+// ============================================================================
+
+func TestServiceAccountsDeleteCmd_Success(t *testing.T) {
+ var deletedName string
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/serviceAccounts/") {
+ http.NotFound(w, r)
+ return
+ }
+ deletedName = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(deletedName, "my-sa@test-project.iam.gserviceaccount.com") {
+ t.Fatalf("expected delete to include email, got: %s", deletedName)
+ }
+ if !strings.Contains(out, "Deleted service account") {
+ t.Fatalf("expected success message, got: %s", out)
+ }
+}
+
+func TestServiceAccountsDeleteCmd_FullResourceName(t *testing.T) {
+ var deletedPath string
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ http.NotFound(w, r)
+ return
+ }
+ deletedPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsDeleteCmd{
+ ServiceAccount: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ // When already a full resource name, it should use it as-is
+ if !strings.Contains(deletedPath, "projects/test-project/serviceAccounts/my-sa") {
+ t.Fatalf("expected full resource name in path, got: %s", deletedPath)
+ }
+}
+
+func TestServiceAccountsDeleteCmd_JSON(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"deleted":true`) && !strings.Contains(out, `"deleted": true`) {
+ t.Fatalf("expected JSON output with deleted:true, got: %s", out)
+ }
+}
+
+func TestServiceAccountsDeleteCmd_EmptyServiceAccount(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsDeleteCmd{ServiceAccount: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty service account")
+ }
+ if !strings.Contains(err.Error(), "service account is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsDeleteCmd_NoForce(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", NoInput: true}
+ cmd := &ServiceAccountsDeleteCmd{ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error without force")
+ }
+ var exitErr *ExitError
+ if !errors.As(err, &exitErr) {
+ t.Fatalf("expected ExitError, got: %v", err)
+ }
+}
+
+func TestServiceAccountsDeleteCmd_APIError(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsDeleteCmd{
+ ServiceAccount: "nonexistent@test-project.iam.gserviceaccount.com",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "delete service account") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsDeleteCmd_NoAccount(t *testing.T) {
+ flags := &RootFlags{Force: true}
+ cmd := &ServiceAccountsDeleteCmd{ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+// ============================================================================
+// ServiceAccountsKeysListCmd Tests
+// ============================================================================
+
+func TestServiceAccountsKeysListCmd_Success(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/keys") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{
+ {
+ "name": "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/key1",
+ "keyType": "USER_MANAGED",
+ "validAfterTime": "2024-01-01T00:00:00Z",
+ "validBeforeTime": "2025-01-01T00:00:00Z",
+ },
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysListCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "USER_MANAGED") {
+ t.Fatalf("expected key type in output, got: %s", out)
+ }
+}
+
+func TestServiceAccountsKeysListCmd_JSON(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{
+ {"name": "key1", "keyType": "USER_MANAGED"},
+ },
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysListCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "keys") {
+ t.Fatalf("expected JSON keys output, got: %s", out)
+ }
+}
+
+func TestServiceAccountsKeysListCmd_EmptyResults(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysListCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ // Should not error even with no keys
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysListCmd_EmptyServiceAccount(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysListCmd{ServiceAccount: ""}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty service account")
+ }
+ if !strings.Contains(err.Error(), "service account is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysListCmd_APIError(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "permission denied", http.StatusForbidden)
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysListCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "list keys") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysListCmd_NoAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &ServiceAccountsKeysListCmd{ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+// ============================================================================
+// ServiceAccountsKeysCreateCmd Tests
+// ============================================================================
+
+func TestServiceAccountsKeysCreateCmd_Success(t *testing.T) {
+ keyData := `{"type":"service_account","project_id":"test-project"}`
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/keys") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/newkey",
+ "privateKeyData": base64.StdEncoding.EncodeToString([]byte(keyData)),
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ tmpDir := t.TempDir()
+ outputPath := filepath.Join(tmpDir, "subdir", "key.json")
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysCreateCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ Output: outputPath,
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created key") {
+ t.Fatalf("expected success message, got: %s", out)
+ }
+
+ // Verify key file was written
+ data, err := os.ReadFile(outputPath)
+ if err != nil {
+ t.Fatalf("failed to read key file: %v", err)
+ }
+ if string(data) != keyData {
+ t.Fatalf("expected key data %q, got %q", keyData, string(data))
+ }
+
+ // Verify file permissions (0600)
+ info, err := os.Stat(outputPath)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("expected 0600 permissions, got %o", info.Mode().Perm())
+ }
+}
+
+func TestServiceAccountsKeysCreateCmd_JSON(t *testing.T) {
+ keyData := `{"type":"service_account"}`
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/newkey",
+ "privateKeyData": base64.StdEncoding.EncodeToString([]byte(keyData)),
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ tmpDir := t.TempDir()
+ outputPath := filepath.Join(tmpDir, "key.json")
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysCreateCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ Output: outputPath,
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "key") || !strings.Contains(out, "output") {
+ t.Fatalf("expected JSON output with key and output, got: %s", out)
+ }
+}
+
+func TestServiceAccountsKeysCreateCmd_EmptyServiceAccount(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysCreateCmd{
+ ServiceAccount: "",
+ Output: "/tmp/key.json",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty service account")
+ }
+ if !strings.Contains(err.Error(), "service account is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysCreateCmd_EmptyOutput(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysCreateCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ Output: " ",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty output")
+ }
+ if !strings.Contains(err.Error(), "--output is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysCreateCmd_APIError(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "quota exceeded", http.StatusTooManyRequests)
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ tmpDir := t.TempDir()
+ outputPath := filepath.Join(tmpDir, "key.json")
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsKeysCreateCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ Output: outputPath,
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "create key") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysCreateCmd_NoAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &ServiceAccountsKeysCreateCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ Output: "/tmp/key.json",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+// ============================================================================
+// ServiceAccountsKeysDeleteCmd Tests
+// ============================================================================
+
+func TestServiceAccountsKeysDeleteCmd_Success(t *testing.T) {
+ var deletedPath string
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/keys/") {
+ http.NotFound(w, r)
+ return
+ }
+ deletedPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "key123",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(deletedPath, "key123") {
+ t.Fatalf("expected key id in deleted path, got: %s", deletedPath)
+ }
+ if !strings.Contains(out, "Deleted key") {
+ t.Fatalf("expected success message, got: %s", out)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_FullResourceName(t *testing.T) {
+ var deletedPath string
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ http.NotFound(w, r)
+ return
+ }
+ deletedPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/key456",
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if !strings.Contains(deletedPath, "key456") {
+ t.Fatalf("expected full key path, got: %s", deletedPath)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_JSON(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "key123",
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, `"deleted":true`) && !strings.Contains(out, `"deleted": true`) {
+ t.Fatalf("expected JSON output with deleted:true, got: %s", out)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_EmptyServiceAccount(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "",
+ KeyID: "key123",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty service account")
+ }
+ if !strings.Contains(err.Error(), "service account and key are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_EmptyKey(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty key")
+ }
+ if !strings.Contains(err.Error(), "service account and key are required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_NoForce(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", NoInput: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "key123",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error without force")
+ }
+ var exitErr *ExitError
+ if !errors.As(err, &exitErr) {
+ t.Fatalf("expected ExitError, got: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_APIError(t *testing.T) {
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "nonexistent",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error from API")
+ }
+ if !strings.Contains(err.Error(), "delete key") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceAccountsKeysDeleteCmd_NoAccount(t *testing.T) {
+ flags := &RootFlags{Force: true}
+ cmd := &ServiceAccountsKeysDeleteCmd{
+ ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com",
+ KeyID: "key123",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+// ============================================================================
+// Helper function tests
+// ============================================================================
+
+func TestNormalizeServiceAccountName(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {
+ input: "my-sa@project.iam.gserviceaccount.com",
+ expected: "projects/-/serviceAccounts/my-sa@project.iam.gserviceaccount.com",
+ },
+ {
+ input: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com",
+ expected: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com",
+ },
+ {
+ input: " my-sa@project.iam.gserviceaccount.com ",
+ expected: "projects/-/serviceAccounts/my-sa@project.iam.gserviceaccount.com",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.input, func(t *testing.T) {
+ result := normalizeServiceAccountName(tc.input)
+ if result != tc.expected {
+ t.Fatalf("normalizeServiceAccountName(%q) = %q, want %q", tc.input, result, tc.expected)
+ }
+ })
+ }
+}
+
+func TestNormalizeServiceAccountKeyName(t *testing.T) {
+ tests := []struct {
+ sa string
+ key string
+ expected string
+ }{
+ {
+ sa: "my-sa@project.iam.gserviceaccount.com",
+ key: "key123",
+ expected: "projects/-/serviceAccounts/my-sa@project.iam.gserviceaccount.com/keys/key123",
+ },
+ {
+ sa: "my-sa@project.iam.gserviceaccount.com",
+ key: "projects/test/serviceAccounts/sa/keys/key456",
+ expected: "projects/test/serviceAccounts/sa/keys/key456",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.key, func(t *testing.T) {
+ result := normalizeServiceAccountKeyName(tc.sa, tc.key)
+ if result != tc.expected {
+ t.Fatalf("normalizeServiceAccountKeyName(%q, %q) = %q, want %q", tc.sa, tc.key, result, tc.expected)
+ }
+ })
+ }
+}
+
+func TestIsValidServiceAccountID(t *testing.T) {
+ tests := []struct {
+ id string
+ valid bool
+ }{
+ {"my-service-account", true},
+ {"mysa", true},
+ {"my-sa-123", true},
+ {"a", true},
+ {"a1", true},
+ {"MyServiceAccount", false}, // uppercase
+ {"1my-sa", false}, // starts with digit
+ {"-my-sa", false}, // starts with hyphen
+ {"my_sa", false}, // underscore
+ {"my.sa", false}, // dot
+ {"my@sa", false}, // @ symbol
+ {"", false}, // empty
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.id, func(t *testing.T) {
+ result := isValidServiceAccountID(tc.id)
+ if result != tc.valid {
+ t.Fatalf("isValidServiceAccountID(%q) = %v, want %v", tc.id, result, tc.valid)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/sites.go b/internal/cmd/sites.go
new file mode 100644
index 00000000..b9e3dda4
--- /dev/null
+++ b/internal/cmd/sites.go
@@ -0,0 +1,179 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/drive/v3"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+const driveMimeGoogleSite = "application/vnd.google-apps.site"
+
+// SitesCmd manages Google Sites (via Drive API).
+type SitesCmd struct {
+ List SitesListCmd `cmd:"" name:"list" aliases:"ls" help:"List sites"`
+ Delete SitesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a site"`
+}
+
+type SitesListCmd struct {
+ User string `name:"user" help:"User email to list sites for"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *SitesListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(c.User) != "" {
+ account = strings.TrimSpace(c.User)
+ }
+
+ svc, err := newDriveService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ query := fmt.Sprintf("mimeType='%s' and trashed=false", driveMimeGoogleSite)
+ call := svc.Files.List().
+ Q(query).
+ Fields("files(id,name,owners(emailAddress),webViewLink,createdTime),nextPageToken").
+ SupportsAllDrives(true).
+ IncludeItemsFromAllDrives(true)
+ if c.Max > 0 {
+ call = call.PageSize(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list sites: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Files) == 0 {
+ u.Err().Println("No sites found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ID\tNAME\tOWNER\tURL\tCREATED")
+ for _, file := range resp.Files {
+ if file == nil {
+ continue
+ }
+ owner := ""
+ if len(file.Owners) > 0 {
+ owner = file.Owners[0].EmailAddress
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
+ sanitizeTab(file.Id),
+ sanitizeTab(file.Name),
+ sanitizeTab(owner),
+ sanitizeTab(file.WebViewLink),
+ sanitizeTab(file.CreatedTime),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type SitesDeleteCmd struct {
+ Site string `arg:"" name:"site" help:"Site ID or URL"`
+}
+
+func (c *SitesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ site := strings.TrimSpace(c.Site)
+ if site == "" {
+ return usage("site is required")
+ }
+
+ svc, err := newDriveService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ siteID, err := resolveSiteID(ctx, svc, site)
+ if err != nil {
+ return err
+ }
+
+ if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete site %s", siteID)); err != nil {
+ return err
+ }
+
+ if err := svc.Files.Delete(siteID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete site %s: %w", siteID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"siteId": siteID, "deleted": true})
+ }
+
+ u.Out().Printf("Deleted site: %s\n", siteID)
+ return nil
+}
+
+func resolveSiteID(ctx context.Context, svc *drive.Service, input string) (string, error) {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return "", usage("site is required")
+ }
+ if !strings.Contains(trimmed, "/") && !strings.Contains(trimmed, "http") {
+ return trimmed, nil
+ }
+
+ pageToken := ""
+ query := fmt.Sprintf("mimeType='%s' and trashed=false", driveMimeGoogleSite)
+ for {
+ call := svc.Files.List().
+ Q(query).
+ Fields("files(id,name,webViewLink),nextPageToken").
+ SupportsAllDrives(true).
+ IncludeItemsFromAllDrives(true)
+ if pageToken != "" {
+ call = call.PageToken(pageToken)
+ }
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return "", fmt.Errorf("resolve site %s: %w", trimmed, err)
+ }
+
+ for _, file := range resp.Files {
+ if file == nil {
+ continue
+ }
+ if file.Id == trimmed {
+ return file.Id, nil
+ }
+ if file.WebViewLink == trimmed || strings.Contains(file.WebViewLink, trimmed) {
+ return file.Id, nil
+ }
+ }
+ if resp.NextPageToken == "" {
+ break
+ }
+ pageToken = resp.NextPageToken
+ }
+
+ return "", fmt.Errorf("could not resolve site %q (use gog sites list to find the site ID)", trimmed)
+}
diff --git a/internal/cmd/sites_test.go b/internal/cmd/sites_test.go
new file mode 100644
index 00000000..7200b4f5
--- /dev/null
+++ b/internal/cmd/sites_test.go
@@ -0,0 +1,74 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestSitesListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "files": []map[string]any{
+ {"id": "site1", "name": "Marketing", "webViewLink": "https://sites.google.com/example.com/marketing", "createdTime": "2026-01-01T00:00:00Z", "owners": []map[string]any{{"emailAddress": "owner@example.com"}}},
+ },
+ })
+ })
+ stubDrive(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &SitesListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Marketing") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSitesDeleteCmd_ResolvesURL(t *testing.T) {
+ var deleted string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "files": []map[string]any{
+ {"id": "site123", "name": "Marketing", "webViewLink": "https://sites.google.com/example.com/marketing"},
+ },
+ })
+ case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/files/site123"):
+ deleted = "site123"
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubDrive(t, h)
+
+ flags := &RootFlags{Account: "user@example.com", Force: true}
+ cmd := &SitesDeleteCmd{Site: "https://sites.google.com/example.com/marketing"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if deleted != "site123" {
+ t.Fatalf("expected delete site123, got %q", deleted)
+ }
+ if !strings.Contains(out, "Deleted site") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
diff --git a/internal/cmd/split_helpers.go b/internal/cmd/split_helpers.go
new file mode 100644
index 00000000..27899ad5
--- /dev/null
+++ b/internal/cmd/split_helpers.go
@@ -0,0 +1,20 @@
+package cmd
+
+import "strings"
+
+func splitCSV(input string) []string {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return nil
+ }
+ parts := strings.Split(trimmed, ",")
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ value := strings.TrimSpace(part)
+ if value == "" {
+ continue
+ }
+ out = append(out, value)
+ }
+ return out
+}
diff --git a/internal/cmd/sso.go b/internal/cmd/sso.go
new file mode 100644
index 00000000..b9ab465f
--- /dev/null
+++ b/internal/cmd/sso.go
@@ -0,0 +1,468 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/cloudidentity/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+const ssoModeOff = "SSO_OFF"
+
+var newInboundSSOService = googleapi.NewCloudIdentityInboundSSO
+
+type SSOCmd struct {
+ Settings SSOSettingsCmd `cmd:"" name:"settings" help:"Inbound SSO settings"`
+ Assignments SSOAssignmentsCmd `cmd:"" name:"assignments" help:"Inbound SSO assignments"`
+}
+
+type SSOSettingsCmd struct {
+ Get SSOSettingsGetCmd `cmd:"" name:"get" help:"Get inbound SSO settings"`
+ Update SSOSettingsUpdateCmd `cmd:"" name:"update" help:"Update inbound SSO settings"`
+}
+
+type SSOSettingsGetCmd struct{}
+
+func (c *SSOSettingsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newInboundSSOService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ profile, err := firstInboundSamlProfile(ctx, svc)
+ if err != nil {
+ return err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, profile)
+ }
+
+ u.Out().Printf("Profile: %s\n", profile.Name)
+ if profile.DisplayName != "" {
+ u.Out().Printf("Display Name: %s\n", profile.DisplayName)
+ }
+ if profile.IdpConfig != nil {
+ if profile.IdpConfig.EntityId != "" {
+ u.Out().Printf("Entity ID: %s\n", profile.IdpConfig.EntityId)
+ }
+ if profile.IdpConfig.SingleSignOnServiceUri != "" {
+ u.Out().Printf("SSO URL: %s\n", profile.IdpConfig.SingleSignOnServiceUri)
+ }
+ if profile.IdpConfig.LogoutRedirectUri != "" {
+ u.Out().Printf("Logout URL: %s\n", profile.IdpConfig.LogoutRedirectUri)
+ }
+ if profile.IdpConfig.ChangePasswordUri != "" {
+ u.Out().Printf("Change Password: %s\n", profile.IdpConfig.ChangePasswordUri)
+ }
+ }
+ if profile.SpConfig != nil {
+ if profile.SpConfig.EntityId != "" {
+ u.Out().Printf("SP Entity ID: %s\n", profile.SpConfig.EntityId)
+ }
+ if profile.SpConfig.AssertionConsumerServiceUri != "" {
+ u.Out().Printf("SP ACS URL: %s\n", profile.SpConfig.AssertionConsumerServiceUri)
+ }
+ }
+ return nil
+}
+
+type SSOSettingsUpdateCmd struct {
+ SSOURL string `name:"sso-url" help:"SSO URL (SingleSignOnService URI)"`
+ LogoutURL string `name:"logout-url" help:"Logout redirect URL"`
+ ChangePasswordURL string `name:"change-password-url" help:"Change password URL"`
+ Certificate string `name:"certificate" help:"IdP signing certificate (PEM string or file path)"`
+}
+
+func (c *SSOSettingsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newInboundSSOService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if strings.TrimSpace(c.SSOURL) == "" && strings.TrimSpace(c.LogoutURL) == "" && strings.TrimSpace(c.ChangePasswordURL) == "" && strings.TrimSpace(c.Certificate) == "" {
+ return usage("no updates specified")
+ }
+
+ profile, err := firstInboundSamlProfile(ctx, svc)
+ if err != nil {
+ return err
+ }
+
+ updateMask := make([]string, 0, 3)
+ patch := &cloudidentity.InboundSamlSsoProfile{}
+
+ idpConfig := &cloudidentity.SamlIdpConfig{}
+ if strings.TrimSpace(c.SSOURL) != "" {
+ idpConfig.SingleSignOnServiceUri = strings.TrimSpace(c.SSOURL)
+ updateMask = append(updateMask, "idpConfig.singleSignOnServiceUri")
+ }
+ if strings.TrimSpace(c.LogoutURL) != "" {
+ idpConfig.LogoutRedirectUri = strings.TrimSpace(c.LogoutURL)
+ updateMask = append(updateMask, "idpConfig.logoutRedirectUri")
+ }
+ if strings.TrimSpace(c.ChangePasswordURL) != "" {
+ idpConfig.ChangePasswordUri = strings.TrimSpace(c.ChangePasswordURL)
+ updateMask = append(updateMask, "idpConfig.changePasswordUri")
+ }
+ if len(updateMask) > 0 {
+ patch.IdpConfig = idpConfig
+ }
+
+ var patchOp *cloudidentity.Operation
+ if len(updateMask) > 0 {
+ patchOp, err = svc.InboundSamlSsoProfiles.Patch(profile.Name, patch).
+ UpdateMask(strings.Join(updateMask, ",")).
+ Context(ctx).
+ Do()
+ if err != nil {
+ return fmt.Errorf("update inbound sso profile: %w", err)
+ }
+ }
+
+ var certOp *cloudidentity.Operation
+ if strings.TrimSpace(c.Certificate) != "" {
+ pemData, err := readValueOrFile(c.Certificate)
+ if err != nil {
+ return fmt.Errorf("read certificate: %w", err)
+ }
+ if strings.TrimSpace(pemData) == "" {
+ return usage("certificate is empty")
+ }
+ certOp, err = svc.InboundSamlSsoProfiles.IdpCredentials.Add(profile.Name, &cloudidentity.AddIdpCredentialRequest{
+ PemData: pemData,
+ }).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("add idp credential: %w", err)
+ }
+ }
+
+ if outfmt.IsJSON(ctx) {
+ payload := map[string]any{"profile": profile.Name}
+ if patchOp != nil {
+ payload["update"] = patchOp
+ }
+ if certOp != nil {
+ payload["certificate"] = certOp
+ }
+ return outfmt.WriteJSON(os.Stdout, payload)
+ }
+
+ if patchOp != nil {
+ u.Out().Printf("Updated inbound SSO profile: %s\n", profile.Name)
+ if patchOp.Name != "" {
+ u.Out().Printf("Update operation: %s\n", patchOp.Name)
+ }
+ }
+ if certOp != nil {
+ if patchOp == nil {
+ u.Out().Printf("Updated inbound SSO profile: %s\n", profile.Name)
+ }
+ if certOp.Name != "" {
+ u.Out().Printf("Certificate operation: %s\n", certOp.Name)
+ }
+ }
+ return nil
+}
+
+type SSOAssignmentsCmd struct {
+ List SSOAssignmentsListCmd `cmd:"" name:"list" help:"List inbound SSO assignments"`
+ Create SSOAssignmentsCreateCmd `cmd:"" name:"create" help:"Create inbound SSO assignment"`
+ Delete SSOAssignmentsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete inbound SSO assignment"`
+}
+
+type SSOAssignmentsListCmd struct {
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *SSOAssignmentsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newInboundSSOService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.InboundSsoAssignments.List()
+ if c.Max > 0 {
+ call = call.PageSize(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list inbound sso assignments: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.InboundSsoAssignments) == 0 {
+ u.Err().Println("No inbound SSO assignments found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ASSIGNMENT ID\tMODE\tTARGET\tPROFILE")
+ for _, assignment := range resp.InboundSsoAssignments {
+ if assignment == nil {
+ continue
+ }
+ target := assignment.TargetOrgUnit
+ if target == "" {
+ target = assignment.TargetGroup
+ }
+ profile := ""
+ if assignment.SamlSsoInfo != nil {
+ profile = assignment.SamlSsoInfo.InboundSamlSsoProfile
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(assignment.Name),
+ sanitizeTab(assignment.SsoMode),
+ sanitizeTab(target),
+ sanitizeTab(profile),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type SSOAssignmentsCreateCmd struct {
+ OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path or ID" required:""`
+ Mode string `name:"mode" help:"SSO mode: SSO_OFF|SSO_ON|NONE" enum:"SSO_OFF,SSO_ON,NONE" required:""`
+}
+
+func (c *SSOAssignmentsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ orgUnit := strings.TrimSpace(c.OrgUnit)
+ if orgUnit == "" {
+ return usage("--org-unit is required")
+ }
+
+ svc, err := newInboundSSOService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ targetOrgUnit, err := resolveOrgUnitResource(ctx, flags, orgUnit)
+ if err != nil {
+ return err
+ }
+
+ mode := strings.ToUpper(strings.TrimSpace(c.Mode))
+ if mode == "NONE" {
+ return clearInboundSSOAssignments(ctx, svc, targetOrgUnit)
+ }
+
+ ssoMode, err := mapInboundSSOMode(mode)
+ if err != nil {
+ return err
+ }
+
+ assignment := &cloudidentity.InboundSsoAssignment{
+ TargetOrgUnit: targetOrgUnit,
+ SsoMode: ssoMode,
+ }
+
+ op, err := svc.InboundSsoAssignments.Create(assignment).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create inbound sso assignment: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, op)
+ }
+
+ u.Out().Printf("Created inbound SSO assignment for %s\n", targetOrgUnit)
+ if op.Name != "" {
+ u.Out().Printf("Operation: %s\n", op.Name)
+ }
+ return nil
+}
+
+type SSOAssignmentsDeleteCmd struct {
+ AssignmentID string `arg:"" name:"assignment-id" help:"Assignment resource name"`
+}
+
+func (c *SSOAssignmentsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if strings.TrimSpace(c.AssignmentID) == "" {
+ return usage("assignment ID is required")
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete inbound SSO assignment %s", c.AssignmentID)); err != nil {
+ return err
+ }
+
+ svc, err := newInboundSSOService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ op, err := svc.InboundSsoAssignments.Delete(c.AssignmentID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("delete inbound sso assignment %s: %w", c.AssignmentID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, op)
+ }
+
+ u.Out().Printf("Deleted inbound SSO assignment: %s\n", c.AssignmentID)
+ if op.Name != "" {
+ u.Out().Printf("Operation: %s\n", op.Name)
+ }
+ return nil
+}
+
+func firstInboundSamlProfile(ctx context.Context, svc *cloudidentity.Service) (*cloudidentity.InboundSamlSsoProfile, error) {
+ resp, err := svc.InboundSamlSsoProfiles.List().Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("list inbound sso profiles: %w", err)
+ }
+ if len(resp.InboundSamlSsoProfiles) == 0 {
+ return nil, fmt.Errorf("no inbound SAML SSO profiles found")
+ }
+ return resp.InboundSamlSsoProfiles[0], nil
+}
+
+func mapInboundSSOMode(mode string) (string, error) {
+ switch strings.ToUpper(strings.TrimSpace(mode)) {
+ case ssoModeOff:
+ return ssoModeOff, nil
+ case "SSO_ON":
+ return "DOMAIN_WIDE_SAML_IF_ENABLED", nil
+ default:
+ return "", usage("mode must be SSO_OFF, SSO_ON, or NONE")
+ }
+}
+
+func resolveOrgUnitResource(ctx context.Context, flags *RootFlags, orgUnit string) (string, error) {
+ if strings.HasPrefix(orgUnit, "orgUnits/") {
+ return orgUnit, nil
+ }
+
+ account, err := requireAccount(flags)
+ if err != nil {
+ return "", err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return "", err
+ }
+
+ ou, err := svc.Orgunits.Get(adminCustomerID(), orgUnit).Context(ctx).Do()
+ if err != nil {
+ return "", fmt.Errorf("resolve org unit %s: %w", orgUnit, err)
+ }
+ if strings.TrimSpace(ou.OrgUnitId) == "" {
+ return "", fmt.Errorf("org unit %s has no ID", orgUnit)
+ }
+ return "orgUnits/" + ou.OrgUnitId, nil
+}
+
+func clearInboundSSOAssignments(ctx context.Context, svc *cloudidentity.Service, targetOrgUnit string) error {
+ u := ui.FromContext(ctx)
+ resp, err := svc.InboundSsoAssignments.List().Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list inbound sso assignments: %w", err)
+ }
+
+ deleted := make([]string, 0)
+ for _, assignment := range resp.InboundSsoAssignments {
+ if assignment == nil || assignment.TargetOrgUnit != targetOrgUnit {
+ continue
+ }
+ if _, err := svc.InboundSsoAssignments.Delete(assignment.Name).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete inbound sso assignment %s: %w", assignment.Name, err)
+ }
+ deleted = append(deleted, assignment.Name)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{
+ "targetOrgUnit": targetOrgUnit,
+ "deleted": deleted,
+ })
+ }
+
+ if len(deleted) == 0 {
+ u.Err().Printf("No inbound SSO assignments found for %s\n", targetOrgUnit)
+ return nil
+ }
+
+ u.Out().Printf("Deleted %d inbound SSO assignments for %s\n", len(deleted), targetOrgUnit)
+ return nil
+}
+
+// readValueOrFile interprets value as either a literal string or a file reference.
+// Prefix with "@" to explicitly read from a file path (e.g. "@/path/to/cert.pem").
+// JSON values (starting with "{" or "[") are returned as-is. As a fallback,
+// if the value happens to match an existing file path on disk it will be read;
+// prefer the "@" prefix to avoid ambiguity.
+func readValueOrFile(value string) (string, error) {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return "", nil
+ }
+ if strings.HasPrefix(trimmed, "@") {
+ path := strings.TrimSpace(strings.TrimPrefix(trimmed, "@"))
+ if path == "" {
+ return "", fmt.Errorf("empty @file path")
+ }
+ data, err := os.ReadFile(path) //nolint:gosec // G304: user-provided file path is intentional
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+ }
+ if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
+ return trimmed, nil
+ }
+ if info, err := os.Stat(trimmed); err == nil && !info.IsDir() {
+ data, err := os.ReadFile(trimmed) //nolint:gosec // G304: user-provided file path is intentional
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+ }
+ return trimmed, nil
+}
diff --git a/internal/cmd/sso_test.go b/internal/cmd/sso_test.go
new file mode 100644
index 00000000..e3526a91
--- /dev/null
+++ b/internal/cmd/sso_test.go
@@ -0,0 +1,1342 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/cloudidentity/v1"
+ "google.golang.org/api/option"
+)
+
+// -----------------------------------------------------------------------------
+// SSOSettingsGetCmd Tests
+// -----------------------------------------------------------------------------
+
+func TestSSOSettingsGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{
+ {
+ "name": "inboundSamlSsoProfiles/profile-1",
+ "displayName": "Workspace",
+ "idpConfig": map[string]any{
+ "entityId": "https://idp.example.com",
+ "singleSignOnServiceUri": "https://sso.example.com",
+ "logoutRedirectUri": "https://logout.example.com",
+ },
+ },
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsGetCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "https://sso.example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSSOSettingsGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{
+ {
+ "name": "inboundSamlSsoProfiles/profile-1",
+ "displayName": "Workspace",
+ "idpConfig": map[string]any{
+ "entityId": "https://idp.example.com",
+ "singleSignOnServiceUri": "https://sso.example.com",
+ },
+ },
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsGetCmd{}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["name"] != "inboundSamlSsoProfiles/profile-1" {
+ t.Fatalf("unexpected name in JSON: %v", result["name"])
+ }
+}
+
+func TestSSOSettingsGetCmd_AllFields(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{
+ {
+ "name": "inboundSamlSsoProfiles/profile-1",
+ "displayName": "My SSO",
+ "idpConfig": map[string]any{
+ "entityId": "https://idp.example.com",
+ "singleSignOnServiceUri": "https://sso.example.com",
+ "logoutRedirectUri": "https://logout.example.com",
+ "changePasswordUri": "https://password.example.com",
+ },
+ "spConfig": map[string]any{
+ "entityId": "https://sp.example.com",
+ "assertionConsumerServiceUri": "https://acs.example.com",
+ },
+ },
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsGetCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ expected := []string{
+ "Profile:",
+ "Display Name:",
+ "Entity ID:",
+ "SSO URL:",
+ "Logout URL:",
+ "Change Password:",
+ "SP Entity ID:",
+ "SP ACS URL:",
+ }
+ for _, exp := range expected {
+ if !strings.Contains(out, exp) {
+ t.Fatalf("expected %q in output: %s", exp, out)
+ }
+ }
+}
+
+func TestSSOSettingsGetCmd_NoProfiles(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{},
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsGetCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no profiles")
+ }
+ if !strings.Contains(err.Error(), "no inbound SAML SSO profiles found") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSSOSettingsGetCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &SSOSettingsGetCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestSSOSettingsGetCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 403,
+ "message": "access denied",
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsGetCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+}
+
+// -----------------------------------------------------------------------------
+// SSOSettingsUpdateCmd Tests
+// -----------------------------------------------------------------------------
+
+func TestSSOSettingsUpdateCmd_SSOURL(t *testing.T) {
+ var gotURL string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{{
+ "name": "inboundSamlSsoProfiles/profile-1",
+ }},
+ })
+ case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/inboundSamlSsoProfiles/profile-1"):
+ var payload map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ if cfg, ok := payload["idpConfig"].(map[string]any); ok {
+ gotURL, _ = cfg["singleSignOnServiceUri"].(string)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-update-1",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{SSOURL: "https://new-sso.example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotURL != "https://new-sso.example.com" {
+ t.Fatalf("expected sso url to be set, got: %s", gotURL)
+ }
+ if !strings.Contains(out, "Updated inbound SSO profile") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSSOSettingsUpdateCmd_MultipleURLs(t *testing.T) {
+ var gotSSOURL, gotLogoutURL, gotPwdURL string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{{
+ "name": "inboundSamlSsoProfiles/profile-1",
+ }},
+ })
+ case r.Method == http.MethodPatch:
+ var payload map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ if cfg, ok := payload["idpConfig"].(map[string]any); ok {
+ gotSSOURL, _ = cfg["singleSignOnServiceUri"].(string)
+ gotLogoutURL, _ = cfg["logoutRedirectUri"].(string)
+ gotPwdURL, _ = cfg["changePasswordUri"].(string)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-update-1",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{
+ SSOURL: "https://sso.example.com",
+ LogoutURL: "https://logout.example.com",
+ ChangePasswordURL: "https://password.example.com",
+ }
+
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotSSOURL != "https://sso.example.com" {
+ t.Fatalf("expected sso url, got: %s", gotSSOURL)
+ }
+ if gotLogoutURL != "https://logout.example.com" {
+ t.Fatalf("expected logout url, got: %s", gotLogoutURL)
+ }
+ if gotPwdURL != "https://password.example.com" {
+ t.Fatalf("expected password url, got: %s", gotPwdURL)
+ }
+}
+
+func TestSSOSettingsUpdateCmd_Certificate(t *testing.T) {
+ var gotPemData string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{{
+ "name": "inboundSamlSsoProfiles/profile-1",
+ }},
+ })
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/idpCredentials:add"):
+ var payload map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotPemData, _ = payload["pemData"].(string)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-cert-1",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPemData == "" {
+ t.Fatal("expected certificate to be set")
+ }
+ if !strings.Contains(out, "Updated inbound SSO profile") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSSOSettingsUpdateCmd_CertificateFromFile(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "cert.pem")
+ certContent := "-----BEGIN CERTIFICATE-----\nfilecontent\n-----END CERTIFICATE-----"
+ if err := os.WriteFile(tmpFile, []byte(certContent), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ var gotPemData string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{{
+ "name": "inboundSamlSsoProfiles/profile-1",
+ }},
+ })
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/idpCredentials:add"):
+ var payload map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotPemData, _ = payload["pemData"].(string)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-cert-1",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{Certificate: "@" + tmpFile}
+
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotPemData != certContent {
+ t.Fatalf("expected certificate from file, got: %s", gotPemData)
+ }
+}
+
+func TestSSOSettingsUpdateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{{
+ "name": "inboundSamlSsoProfiles/profile-1",
+ }},
+ })
+ case r.Method == http.MethodPatch:
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-update-1",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{SSOURL: "https://sso.example.com"}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["profile"] != "inboundSamlSsoProfiles/profile-1" {
+ t.Fatalf("unexpected profile in JSON: %v", result["profile"])
+ }
+}
+
+func TestSSOSettingsUpdateCmd_NoUpdates(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for no updates")
+ }
+ if !strings.Contains(err.Error(), "no updates specified") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSSOSettingsUpdateCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &SSOSettingsUpdateCmd{SSOURL: "https://sso.example.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestSSOSettingsUpdateCmd_EmptyCertificate(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "empty.pem")
+ if err := os.WriteFile(tmpFile, []byte(" "), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSamlSsoProfiles": []map[string]any{{
+ "name": "inboundSamlSsoProfiles/profile-1",
+ }},
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsUpdateCmd{Certificate: "@" + tmpFile}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty certificate")
+ }
+ if !strings.Contains(err.Error(), "certificate is empty") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// -----------------------------------------------------------------------------
+// SSOAssignmentsListCmd Tests
+// -----------------------------------------------------------------------------
+
+func TestSSOAssignmentsListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{
+ {
+ "name": "inboundSsoAssignments/assignment-1",
+ "ssoMode": "SSO_OFF",
+ "targetOrgUnit": "orgUnits/ou-123",
+ },
+ },
+ "nextPageToken": "token-123",
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsListCmd{}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result struct {
+ InboundSsoAssignments []struct {
+ Name string `json:"name"`
+ SsoMode string `json:"ssoMode"`
+ TargetOrgUnit string `json:"targetOrgUnit"`
+ } `json:"inboundSsoAssignments"`
+ NextPageToken string `json:"nextPageToken"`
+ }
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result.NextPageToken != "token-123" {
+ t.Fatalf("unexpected nextPageToken: %v", result.NextPageToken)
+ }
+ if len(result.InboundSsoAssignments) != 1 {
+ t.Fatalf("expected 1 assignment, got %d", len(result.InboundSsoAssignments))
+ }
+ if result.InboundSsoAssignments[0].Name != "inboundSsoAssignments/assignment-1" {
+ t.Fatalf("unexpected assignment name: %s", result.InboundSsoAssignments[0].Name)
+ }
+ if result.InboundSsoAssignments[0].SsoMode != "SSO_OFF" {
+ t.Fatalf("unexpected assignment mode: %s", result.InboundSsoAssignments[0].SsoMode)
+ }
+}
+
+func TestSSOAssignmentsListCmd_Empty(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{},
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsListCmd{}
+
+ // Should not error on empty list
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestSSOAssignmentsListCmd_WithTargetGroup(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{
+ {
+ "name": "inboundSsoAssignments/assignment-2",
+ "ssoMode": "DOMAIN_WIDE_SAML_IF_ENABLED",
+ "targetGroup": "groups/group-123",
+ },
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "groups/group-123") {
+ t.Fatalf("expected group target in output: %s", out)
+ }
+}
+
+func TestSSOAssignmentsListCmd_WithPagination(t *testing.T) {
+ var gotPageSize, gotPageToken string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPageSize = r.URL.Query().Get("pageSize")
+ gotPageToken = r.URL.Query().Get("pageToken")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{},
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsListCmd{Max: 50, Page: "next-page-token"}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotPageSize != "50" {
+ t.Fatalf("expected pageSize 50, got: %s", gotPageSize)
+ }
+ if gotPageToken != "next-page-token" {
+ t.Fatalf("expected pageToken, got: %s", gotPageToken)
+ }
+}
+
+func TestSSOAssignmentsListCmd_NilAssignment(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ // API might return null entries
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []any{
+ nil,
+ map[string]any{
+ "name": "inboundSsoAssignments/assignment-1",
+ "ssoMode": "SSO_OFF",
+ "targetOrgUnit": "orgUnits/ou-123",
+ },
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "assignment-1") {
+ t.Fatalf("expected assignment in output: %s", out)
+ }
+}
+
+func TestSSOAssignmentsListCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &SSOAssignmentsListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestSSOAssignmentsListCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 500,
+ "message": "internal error",
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsListCmd{}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+}
+
+// -----------------------------------------------------------------------------
+// SSOAssignmentsCreateCmd Tests
+// -----------------------------------------------------------------------------
+
+func TestSSOAssignmentsCreateCmd(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "ou-123",
+ "orgUnitPath": "/Sales",
+ })
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ var gotTarget, gotMode string
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments"):
+ var payload struct {
+ TargetOrgUnit string `json:"targetOrgUnit"`
+ SsoMode string `json:"ssoMode"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotTarget = payload.TargetOrgUnit
+ gotMode = payload.SsoMode
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-1",
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Sales", Mode: "SSO_ON"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotTarget != "orgUnits/ou-123" {
+ t.Fatalf("expected target orgUnits/ou-123, got %q", gotTarget)
+ }
+ if gotMode != "DOMAIN_WIDE_SAML_IF_ENABLED" {
+ t.Fatalf("expected mode DOMAIN_WIDE_SAML_IF_ENABLED, got %q", gotMode)
+ }
+ if !strings.Contains(out, "Created inbound SSO assignment") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_SSOOff(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "ou-456",
+ "orgUnitPath": "/Engineering",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ var gotMode string
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") {
+ var payload struct {
+ SsoMode string `json:"ssoMode"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotMode = payload.SsoMode
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-2",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Engineering", Mode: "SSO_OFF"}
+
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotMode != "SSO_OFF" {
+ t.Fatalf("expected mode SSO_OFF, got %q", gotMode)
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_DirectOrgUnitID(t *testing.T) {
+ // When orgUnit already starts with "orgUnits/", no lookup needed
+ var gotTarget string
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") {
+ var payload struct {
+ TargetOrgUnit string `json:"targetOrgUnit"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ gotTarget = payload.TargetOrgUnit
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-3",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "orgUnits/direct-ou", Mode: "SSO_OFF"}
+
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if gotTarget != "orgUnits/direct-ou" {
+ t.Fatalf("expected direct org unit, got %q", gotTarget)
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_ModeNone_ClearAssignments(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "ou-789",
+ "orgUnitPath": "/Marketing",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ var deleteCalled bool
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{
+ {
+ "name": "inboundSsoAssignments/assign-to-delete",
+ "targetOrgUnit": "orgUnits/ou-789",
+ },
+ },
+ })
+ case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "assign-to-delete"):
+ deleteCalled = true
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-delete",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Marketing", Mode: "NONE"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !deleteCalled {
+ t.Fatal("expected delete to be called for NONE mode")
+ }
+ if !strings.Contains(out, "Deleted 1 inbound SSO assignments") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_ModeNone_NoAssignments(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "ou-empty",
+ "orgUnitPath": "/Empty",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{},
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Empty", Mode: "NONE"}
+
+ // Should not error when no assignments to delete
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_JSON(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "ou-json",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-json",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "SSO_OFF"}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["name"] != "operations/op-json" {
+ t.Fatalf("unexpected operation name: %v", result["name"])
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "SSO_ON"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestSSOAssignmentsCreateCmd_EmptyOrgUnit(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: " ", Mode: "SSO_ON"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty org unit")
+ }
+ if !strings.Contains(err.Error(), "--org-unit is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// -----------------------------------------------------------------------------
+// SSOAssignmentsDeleteCmd Tests
+// -----------------------------------------------------------------------------
+
+func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) {
+ var deleteCalled bool
+ var deletedID string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments/") {
+ deleteCalled = true
+ deletedID = strings.TrimPrefix(r.URL.Path, "/v1/inboundSsoAssignments/")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-delete-json",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-json"}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if !deleteCalled {
+ t.Fatal("delete was not called")
+ }
+ if deletedID != "assignment-json" {
+ t.Fatalf("unexpected deleted ID: %s", deletedID)
+ }
+ if result["name"] != "operations/op-delete-json" {
+ t.Fatalf("unexpected operation name: %v", result["name"])
+ }
+}
+
+func TestSSOAssignmentsDeleteCmd_RequiresConfirmation(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", NoInput: true}
+ cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing confirmation")
+ }
+}
+
+func TestSSOAssignmentsDeleteCmd_EmptyID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SSOAssignmentsDeleteCmd{AssignmentID: " "}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty assignment ID")
+ }
+ if !strings.Contains(err.Error(), "assignment ID is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestSSOAssignmentsDeleteCmd_MissingAccount(t *testing.T) {
+ flags := &RootFlags{}
+ cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-1"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing account")
+ }
+}
+
+func TestSSOAssignmentsDeleteCmd_APIError(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{
+ "code": 404,
+ "message": "assignment not found",
+ },
+ })
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/nonexistent"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for API failure")
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Helper Function Tests
+// -----------------------------------------------------------------------------
+
+func TestMapInboundSSOMode(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ wantErr bool
+ }{
+ {"SSO_OFF", "SSO_OFF", false},
+ {"sso_off", "SSO_OFF", false},
+ {" SSO_OFF ", "SSO_OFF", false},
+ {"SSO_ON", "DOMAIN_WIDE_SAML_IF_ENABLED", false},
+ {"sso_on", "DOMAIN_WIDE_SAML_IF_ENABLED", false},
+ {"INVALID", "", true},
+ {"", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := mapInboundSSOMode(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("mapInboundSSOMode(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
+ }
+ if got != tt.expected {
+ t.Fatalf("mapInboundSSOMode(%q) = %q, want %q", tt.input, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestReadValueOrFile(t *testing.T) {
+ // Test empty value
+ result, err := readValueOrFile("")
+ if err != nil || result != "" {
+ t.Fatalf("expected empty result for empty input, got: %q, err: %v", result, err)
+ }
+
+ // Test whitespace-only value
+ result, err = readValueOrFile(" ")
+ if err != nil || result != "" {
+ t.Fatalf("expected empty result for whitespace input, got: %q, err: %v", result, err)
+ }
+
+ // Test direct value
+ result, err = readValueOrFile("direct-value")
+ if err != nil || result != "direct-value" {
+ t.Fatalf("expected direct value, got: %q, err: %v", result, err)
+ }
+
+ // Test JSON object
+ result, err = readValueOrFile(`{"key": "value"}`)
+ if err != nil || result != `{"key": "value"}` {
+ t.Fatalf("expected JSON object, got: %q, err: %v", result, err)
+ }
+
+ // Test JSON array
+ result, err = readValueOrFile(`["a", "b"]`)
+ if err != nil || result != `["a", "b"]` {
+ t.Fatalf("expected JSON array, got: %q, err: %v", result, err)
+ }
+
+ // Test @file syntax
+ tmpFile := filepath.Join(t.TempDir(), "testfile.txt")
+ if err = os.WriteFile(tmpFile, []byte("file-content"), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ result, err = readValueOrFile("@" + tmpFile)
+ if err != nil || result != "file-content" {
+ t.Fatalf("expected file content, got: %q, err: %v", result, err)
+ }
+
+ // Test @file with empty path
+ _, err = readValueOrFile("@")
+ if err == nil {
+ t.Fatal("expected error for empty @file path")
+ }
+ if !strings.Contains(err.Error(), "empty @file path") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Test @file with spaces around path
+ result, err = readValueOrFile("@ " + tmpFile + " ")
+ if err != nil || result != "file-content" {
+ t.Fatalf("expected file content with trimmed path, got: %q, err: %v", result, err)
+ }
+
+ // Test file path detection (file exists)
+ tmpFile2 := filepath.Join(t.TempDir(), "detectfile.txt")
+ if err = os.WriteFile(tmpFile2, []byte("detected-content"), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ result, err = readValueOrFile(tmpFile2)
+ if err != nil || result != "detected-content" {
+ t.Fatalf("expected detected file content, got: %q, err: %v", result, err)
+ }
+
+ // Test nonexistent file in @syntax
+ _, err = readValueOrFile("@/nonexistent/file.txt")
+ if err == nil {
+ t.Fatal("expected error for nonexistent @file")
+ }
+}
+
+func TestResolveOrgUnitResource_DirectID(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+
+ result, err := resolveOrgUnitResource(context.Background(), flags, "orgUnits/direct-123")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result != "orgUnits/direct-123" {
+ t.Fatalf("expected direct ID passthrough, got: %s", result)
+ }
+}
+
+func TestResolveOrgUnitResource_Lookup(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "resolved-ou-id",
+ "orgUnitPath": "/Resolved",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+
+ result, err := resolveOrgUnitResource(testContext(t), flags, "/Resolved")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result != "orgUnits/resolved-ou-id" {
+ t.Fatalf("expected resolved org unit ID, got: %s", result)
+ }
+}
+
+func TestResolveOrgUnitResource_EmptyOrgUnitID(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "",
+ "orgUnitPath": "/NoID",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+
+ _, err := resolveOrgUnitResource(testContext(t), flags, "/NoID")
+ if err == nil {
+ t.Fatal("expected error for empty org unit ID")
+ }
+ if !strings.Contains(err.Error(), "has no ID") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestClearInboundSSOAssignments_JSON(t *testing.T) {
+ adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "orgUnitId": "ou-json-clear",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, adminHandler)
+
+ cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "inboundSsoAssignments": []map[string]any{
+ {
+ "name": "inboundSsoAssignments/assign-1",
+ "targetOrgUnit": "orgUnits/ou-json-clear",
+ },
+ },
+ })
+ case r.Method == http.MethodDelete:
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "operations/op-del",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ stubInboundSSO(t, cloudHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "NONE"}
+
+ ctx := testContextJSON(t)
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result map[string]any
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("failed to parse JSON: %v", err)
+ }
+ if result["targetOrgUnit"] != "orgUnits/ou-json-clear" {
+ t.Fatalf("unexpected targetOrgUnit: %v", result["targetOrgUnit"])
+ }
+ deleted, ok := result["deleted"].([]any)
+ if !ok || len(deleted) != 1 {
+ t.Fatalf("expected 1 deleted assignment, got: %v", result["deleted"])
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test Helper Functions
+// -----------------------------------------------------------------------------
+
+func stubInboundSSO(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newInboundSSOService
+ svc, err := cloudidentity.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new cloudidentity service: %v", err)
+ }
+ newInboundSSOService = func(context.Context, string) (*cloudidentity.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newInboundSSOService = orig
+ srv.Close()
+ })
+}
diff --git a/internal/cmd/tasks_helpers_test.go b/internal/cmd/tasks_helpers_test.go
new file mode 100644
index 00000000..bf639ff1
--- /dev/null
+++ b/internal/cmd/tasks_helpers_test.go
@@ -0,0 +1,987 @@
+package cmd
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+// =============================================================================
+// Tasks Repeat Tests
+// =============================================================================
+
+func TestParseRepeatUnit(t *testing.T) {
+ tests := []struct {
+ input string
+ want repeatUnit
+ wantErr bool
+ }{
+ {"", repeatNone, false},
+ {" ", repeatNone, false},
+ {"daily", repeatDaily, false},
+ {"day", repeatDaily, false},
+ {"DAILY", repeatDaily, false},
+ {" Daily ", repeatDaily, false},
+ {"weekly", repeatWeekly, false},
+ {"week", repeatWeekly, false},
+ {"WEEKLY", repeatWeekly, false},
+ {"monthly", repeatMonthly, false},
+ {"month", repeatMonthly, false},
+ {"MONTHLY", repeatMonthly, false},
+ {"yearly", repeatYearly, false},
+ {"year", repeatYearly, false},
+ {"annually", repeatYearly, false},
+ {"YEARLY", repeatYearly, false},
+ {"invalid", repeatNone, true},
+ {"bi-weekly", repeatNone, true},
+ {"hourly", repeatNone, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := parseRepeatUnit(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseRepeatUnit(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("parseRepeatUnit(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseTaskDate(t *testing.T) {
+ tests := []struct {
+ input string
+ wantYear int
+ wantMonth time.Month
+ wantDay int
+ hasTime bool
+ wantErr bool
+ }{
+ {"", 0, 0, 0, false, true},
+ {" ", 0, 0, 0, false, true},
+ {"2025-01-15", 2025, time.January, 15, false, false},
+ {"2025-12-31", 2025, time.December, 31, false, false},
+ {"2025-01-15T10:30:00Z", 2025, time.January, 15, true, false},
+ {"2025-01-15T10:30:00.123456789Z", 2025, time.January, 15, true, false},
+ {"2025-01-15T10:30:00", 2025, time.January, 15, true, false},
+ {"2025-01-15 10:30", 2025, time.January, 15, true, false},
+ {"not-a-date", 0, 0, 0, false, true},
+ {"2025/01/15", 0, 0, 0, false, true},
+ {"01-15-2025", 0, 0, 0, false, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, hasTime, err := parseTaskDate(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseTaskDate(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
+ return
+ }
+ if tt.wantErr {
+ return
+ }
+ if hasTime != tt.hasTime {
+ t.Errorf("parseTaskDate(%q) hasTime = %v, want %v", tt.input, hasTime, tt.hasTime)
+ }
+ if got.Year() != tt.wantYear || got.Month() != tt.wantMonth || got.Day() != tt.wantDay {
+ t.Errorf("parseTaskDate(%q) = %v, want year=%d month=%d day=%d", tt.input, got, tt.wantYear, tt.wantMonth, tt.wantDay)
+ }
+ })
+ }
+}
+
+func TestAddRepeat(t *testing.T) {
+ base := time.Date(2025, time.January, 15, 10, 0, 0, 0, time.UTC)
+
+ tests := []struct {
+ name string
+ unit repeatUnit
+ n int
+ want time.Time
+ }{
+ {"none 0", repeatNone, 0, base},
+ {"none 5", repeatNone, 5, base},
+ {"daily 0", repeatDaily, 0, base},
+ {"daily 1", repeatDaily, 1, time.Date(2025, time.January, 16, 10, 0, 0, 0, time.UTC)},
+ {"daily 7", repeatDaily, 7, time.Date(2025, time.January, 22, 10, 0, 0, 0, time.UTC)},
+ {"weekly 0", repeatWeekly, 0, base},
+ {"weekly 1", repeatWeekly, 1, time.Date(2025, time.January, 22, 10, 0, 0, 0, time.UTC)},
+ {"weekly 4", repeatWeekly, 4, time.Date(2025, time.February, 12, 10, 0, 0, 0, time.UTC)},
+ {"monthly 0", repeatMonthly, 0, base},
+ {"monthly 1", repeatMonthly, 1, time.Date(2025, time.February, 15, 10, 0, 0, 0, time.UTC)},
+ {"monthly 12", repeatMonthly, 12, time.Date(2026, time.January, 15, 10, 0, 0, 0, time.UTC)},
+ {"yearly 0", repeatYearly, 0, base},
+ {"yearly 1", repeatYearly, 1, time.Date(2026, time.January, 15, 10, 0, 0, 0, time.UTC)},
+ {"yearly 5", repeatYearly, 5, time.Date(2030, time.January, 15, 10, 0, 0, 0, time.UTC)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := addRepeat(base, tt.unit, tt.n)
+ if !got.Equal(tt.want) {
+ t.Errorf("addRepeat(%v, %v, %d) = %v, want %v", base, tt.unit, tt.n, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExpandRepeatSchedule(t *testing.T) {
+ start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+ tests := []struct {
+ name string
+ unit repeatUnit
+ count int
+ until *time.Time
+ want int
+ }{
+ {"none", repeatNone, 0, nil, 1},
+ {"no count no until", repeatDaily, 0, nil, 1},
+ {"count 3 daily", repeatDaily, 3, nil, 3},
+ {"count 5 weekly", repeatWeekly, 5, nil, 5},
+ {"count 2 monthly", repeatMonthly, 2, nil, 2},
+ {"count 4 yearly", repeatYearly, 4, nil, 4},
+ {"negative count", repeatDaily, -1, nil, 1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := expandRepeatSchedule(start, tt.unit, tt.count, tt.until)
+ if len(got) != tt.want {
+ t.Errorf("expandRepeatSchedule() returned %d items, want %d", len(got), tt.want)
+ }
+ })
+ }
+
+ // Test with until date
+ t.Run("until date", func(t *testing.T) {
+ until := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC)
+ got := expandRepeatSchedule(start, repeatDaily, 0, &until)
+ if len(got) != 5 {
+ t.Errorf("expandRepeatSchedule with until returned %d items, want 5", len(got))
+ }
+ })
+
+ // Test count takes precedence over until
+ t.Run("count limits before until", func(t *testing.T) {
+ until := time.Date(2025, time.January, 10, 0, 0, 0, 0, time.UTC)
+ got := expandRepeatSchedule(start, repeatDaily, 3, &until)
+ if len(got) != 3 {
+ t.Errorf("expandRepeatSchedule with count and until returned %d items, want 3", len(got))
+ }
+ })
+}
+
+func TestFormatTaskDue(t *testing.T) {
+ ts := time.Date(2025, time.January, 15, 10, 30, 0, 0, time.UTC)
+
+ tests := []struct {
+ name string
+ hasTime bool
+ want string
+ }{
+ {"with time", true, "2025-01-15T10:30:00Z"},
+ {"without time", false, "2025-01-15T10:30:00Z"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatTaskDue(ts, tt.hasTime)
+ if got != tt.want {
+ t.Errorf("formatTaskDue() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// CSV Helper Tests
+// =============================================================================
+
+func TestSplitCSVFields(t *testing.T) {
+ tests := []struct {
+ input string
+ want []string
+ }{
+ {"", nil},
+ {" ", nil},
+ {"field1", []string{"field1"}},
+ {"field1,field2", []string{"field1", "field2"}},
+ {"field1, field2, field3", []string{"field1", "field2", "field3"}},
+ {" field1 , field2 ", []string{"field1", "field2"}},
+ {"field1,,field2", []string{"field1", "field2"}},
+ {",,", []string{}},
+ {"a,b,c,d,e", []string{"a", "b", "c", "d", "e"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := splitCSV(tt.input)
+ if tt.want == nil && got != nil {
+ t.Errorf("splitCSV(%q) = %v, want nil", tt.input, got)
+ return
+ }
+ if tt.want != nil && got == nil {
+ t.Errorf("splitCSV(%q) = nil, want %v", tt.input, tt.want)
+ return
+ }
+ if len(got) != len(tt.want) {
+ t.Errorf("splitCSV(%q) = %v, want %v", tt.input, got, tt.want)
+ return
+ }
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("splitCSV(%q)[%d] = %v, want %v", tt.input, i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
+// =============================================================================
+// Completion Tests
+// =============================================================================
+
+func TestCompletionScript(t *testing.T) {
+ tests := []struct {
+ shell string
+ marker string
+ wantErr bool
+ }{
+ {"bash", "complete -F _gog_complete gog", false},
+ {"zsh", "bashcompinit", false},
+ {"fish", "complete -c gog", false},
+ {"powershell", "Register-ArgumentCompleter", false},
+ {"invalid", "", true},
+ {"sh", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.shell, func(t *testing.T) {
+ got, err := completionScript(tt.shell)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("completionScript(%q) error = %v, wantErr %v", tt.shell, err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && !strings.Contains(got, tt.marker) {
+ t.Errorf("completionScript(%q) = %q, expected to contain %q", tt.shell, got, tt.marker)
+ }
+ })
+ }
+}
+
+func TestNormalizeCword(t *testing.T) {
+ tests := []struct {
+ name string
+ cword int
+ wordCount int
+ want int
+ }{
+ {"negative cword", -1, 3, 2},
+ {"negative cword empty", -1, 0, -1},
+ {"zero cword", 0, 3, 0},
+ {"valid cword", 2, 5, 2},
+ {"cword exceeds count", 10, 3, 3},
+ {"cword equals count", 3, 3, 3},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeCword(tt.cword, tt.wordCount)
+ if got != tt.want {
+ t.Errorf("normalizeCword(%d, %d) = %d, want %d", tt.cword, tt.wordCount, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCompletionStartIndex(t *testing.T) {
+ tests := []struct {
+ name string
+ words []string
+ want int
+ }{
+ {"empty", []string{}, 0},
+ {"gog command", []string{"gog"}, 1},
+ {"GOG command", []string{"GOG"}, 1},
+ {"gog.exe command", []string{"gog.exe"}, 1},
+ {"path to gog", []string{"/usr/bin/gog"}, 1},
+ {"non-gog command", []string{"other"}, 0},
+ {"subcommand", []string{"auth"}, 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := completionStartIndex(tt.words)
+ if got != tt.want {
+ t.Errorf("completionStartIndex(%v) = %d, want %d", tt.words, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsProgramName(t *testing.T) {
+ tests := []struct {
+ word string
+ want bool
+ }{
+ {"gog", true},
+ {"GOG", true},
+ {"gog.exe", true},
+ {"GOG.EXE", true},
+ {"/usr/bin/gog", true},
+ {"/usr/local/bin/GOG", true},
+ // Windows paths are handled by filepath.Base which works differently on Unix
+ // {"C:\\Program Files\\gog.exe", true}, // Skipped: filepath.Base behavior varies by OS
+ {"other", false},
+ {"gogg", false},
+ {"notgog", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.word, func(t *testing.T) {
+ got := isProgramName(tt.word)
+ if got != tt.want {
+ t.Errorf("isProgramName(%q) = %v, want %v", tt.word, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSplitFlagToken(t *testing.T) {
+ tests := []struct {
+ word string
+ wantFlag string
+ hasValue bool
+ }{
+ {"--flag", "--flag", false},
+ {"--flag=value", "--flag", true},
+ {"-f", "-f", false},
+ {"-f=v", "-f", true},
+ {"--long-flag=some-value", "--long-flag", true},
+ {"--flag=", "--flag", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.word, func(t *testing.T) {
+ gotFlag, gotHasValue := splitFlagToken(tt.word)
+ if gotFlag != tt.wantFlag {
+ t.Errorf("splitFlagToken(%q) flag = %q, want %q", tt.word, gotFlag, tt.wantFlag)
+ }
+ if gotHasValue != tt.hasValue {
+ t.Errorf("splitFlagToken(%q) hasValue = %v, want %v", tt.word, gotHasValue, tt.hasValue)
+ }
+ })
+ }
+}
+
+func TestMatchingCommands(t *testing.T) {
+ node := &completionNode{
+ children: map[string]*completionNode{
+ "auth": {},
+ "calendar": {},
+ "contacts": {},
+ "drive": {},
+ },
+ }
+
+ tests := []struct {
+ prefix string
+ want int
+ }{
+ {"", 4},
+ {"a", 1},
+ {"c", 2},
+ {"d", 1},
+ {"x", 0},
+ {"auth", 1},
+ {"cal", 1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.prefix, func(t *testing.T) {
+ got := matchingCommands(node, tt.prefix)
+ if len(got) != tt.want {
+ t.Errorf("matchingCommands(node, %q) returned %d items, want %d", tt.prefix, len(got), tt.want)
+ }
+ })
+ }
+}
+
+func TestMatchingFlags(t *testing.T) {
+ node := &completionNode{
+ flags: map[string]completionFlag{
+ "--help": {takesValue: false},
+ "--verbose": {takesValue: false},
+ "--account": {takesValue: true},
+ "-h": {takesValue: false},
+ },
+ }
+
+ tests := []struct {
+ prefix string
+ want int
+ }{
+ {"", 4},
+ {"-", 4},
+ {"--", 3},
+ {"--h", 1},
+ {"--v", 1},
+ {"--a", 1},
+ {"-h", 1},
+ {"--x", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.prefix, func(t *testing.T) {
+ got := matchingFlags(node, tt.prefix)
+ if len(got) != tt.want {
+ t.Errorf("matchingFlags(node, %q) returned %d items, want %d", tt.prefix, len(got), tt.want)
+ }
+ })
+ }
+}
+
+func TestShouldStopAfterTerminator(t *testing.T) {
+ tests := []struct {
+ name string
+ terminatorIndex int
+ cword int
+ words []string
+ want bool
+ }{
+ {"no terminator", -1, 2, []string{"gog", "auth", "list"}, false},
+ {"terminator before cword", 2, 3, []string{"gog", "auth", "--", "extra"}, true},
+ {"terminator at cword", 2, 2, []string{"gog", "auth", "--"}, true},
+ {"cword is terminator", -1, 2, []string{"gog", "auth", "--"}, true},
+ {"terminator after cword", 3, 2, []string{"gog", "auth", "list", "--"}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := shouldStopAfterTerminator(tt.terminatorIndex, tt.cword, tt.words)
+ if got != tt.want {
+ t.Errorf("shouldStopAfterTerminator(%d, %d, %v) = %v, want %v",
+ tt.terminatorIndex, tt.cword, tt.words, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCompleteWordsEmpty(t *testing.T) {
+ got, err := completeWords(0, nil)
+ if err != nil {
+ t.Fatalf("completeWords: %v", err)
+ }
+ if got != nil {
+ t.Errorf("completeWords(0, nil) = %v, want nil", got)
+ }
+
+ got, err = completeWords(0, []string{})
+ if err != nil {
+ t.Fatalf("completeWords: %v", err)
+ }
+ if got != nil {
+ t.Errorf("completeWords(0, []) = %v, want nil", got)
+ }
+}
+
+func TestCompletionInternalCmd(t *testing.T) {
+ cmd := &CompletionInternalCmd{
+ Cword: 1,
+ Words: []string{"gog", "a"},
+ }
+
+ out := captureStdout(t, func() {
+ err := cmd.Run(context.Background())
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ // Should return completions starting with 'a' like 'auth'
+ if !strings.Contains(out, "auth") {
+ t.Errorf("expected 'auth' in completion output, got %q", out)
+ }
+}
+
+// =============================================================================
+// ToDrive Helper Tests
+// =============================================================================
+
+func TestToDriveNumber(t *testing.T) {
+ tests := []struct {
+ value int64
+ want string
+ }{
+ {0, "0"},
+ {1, "1"},
+ {-1, "-1"},
+ {12345, "12345"},
+ {9223372036854775807, "9223372036854775807"},
+ {-9223372036854775808, "-9223372036854775808"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.want, func(t *testing.T) {
+ got := toDriveNumber(tt.value)
+ if got != tt.want {
+ t.Errorf("toDriveNumber(%d) = %q, want %q", tt.value, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestToDriveBool(t *testing.T) {
+ if got := toDriveBool(true); got != "true" {
+ t.Errorf("toDriveBool(true) = %q, want %q", got, "true")
+ }
+ if got := toDriveBool(false); got != "false" {
+ t.Errorf("toDriveBool(false) = %q, want %q", got, "false")
+ }
+}
+
+func TestToDriveRow(t *testing.T) {
+ tests := []struct {
+ name string
+ values []string
+ }{
+ {"empty", []string{}},
+ {"single", []string{"a"}},
+ {"multiple", []string{"a", "b", "c"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := toDriveRow(tt.values...)
+ if len(got) != len(tt.values) {
+ t.Errorf("toDriveRow returned %d items, want %d", len(got), len(tt.values))
+ }
+ for i, v := range tt.values {
+ if got[i] != v {
+ t.Errorf("toDriveRow[%d] = %q, want %q", i, got[i], v)
+ }
+ }
+ })
+ }
+}
+
+func TestToDriveTitle(t *testing.T) {
+ tests := []struct {
+ name string
+ base string
+ sheetName string
+ want string
+ }{
+ {"use base", "Base Title", "", "Base Title"},
+ {"use sheet name", "Base Title", "Custom Sheet", "Custom Sheet"},
+ {"empty base", "", "Custom Sheet", "Custom Sheet"},
+ {"empty both", "", "", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := ToDriveFlags{SheetName: tt.sheetName}
+ got := toDriveTitle(tt.base, opts)
+ if got != tt.want {
+ t.Errorf("toDriveTitle(%q, {SheetName: %q}) = %q, want %q",
+ tt.base, tt.sheetName, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestToDriveFlagsEnabled(t *testing.T) {
+ tests := []struct {
+ name string
+ flags ToDriveFlags
+ enabled bool
+ }{
+ {"disabled", ToDriveFlags{ToDrive: false}, false},
+ {"enabled", ToDriveFlags{ToDrive: true}, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.flags.enabled()
+ if got != tt.enabled {
+ t.Errorf("ToDriveFlags{ToDrive: %v}.enabled() = %v, want %v",
+ tt.flags.ToDrive, got, tt.enabled)
+ }
+ })
+ }
+}
+
+func TestWriteToDriveDisabled(t *testing.T) {
+ ctx := context.Background()
+ flags := &RootFlags{Account: "test@example.com"}
+ opts := ToDriveFlags{ToDrive: false}
+
+ handled, err := writeToDrive(ctx, flags, "title", []string{"h1"}, [][]string{{"r1"}}, opts)
+ if err != nil {
+ t.Fatalf("writeToDrive: %v", err)
+ }
+ if handled {
+ t.Error("writeToDrive should return handled=false when ToDrive is disabled")
+ }
+}
+
+func TestWriteToDriveMissingAccount(t *testing.T) {
+ ctx := context.Background()
+ flags := &RootFlags{Account: ""}
+ opts := ToDriveFlags{ToDrive: true}
+
+ handled, err := writeToDrive(ctx, flags, "title", []string{"h1"}, [][]string{{"r1"}}, opts)
+ if !handled {
+ t.Error("writeToDrive should return handled=true when enabled")
+ }
+ if err == nil {
+ t.Error("writeToDrive should return error when account is missing")
+ }
+}
+
+// =============================================================================
+// Timezone Tests
+// =============================================================================
+
+func TestWarnInvalidConfigTimezone(t *testing.T) {
+ // Test fallback mode warning
+ t.Run("fallback mode", func(t *testing.T) {
+ out := captureStderr(t, func() {
+ warnInvalidConfigTimezone("InvalidTZ", timezoneWithFallback)
+ })
+ if !strings.Contains(out, "warning") || !strings.Contains(out, "InvalidTZ") {
+ t.Errorf("expected warning about InvalidTZ, got %q", out)
+ }
+ if !strings.Contains(out, "using local timezone") {
+ t.Errorf("expected 'using local timezone' in fallback warning, got %q", out)
+ }
+ })
+
+ // Test explicit-only mode warning
+ t.Run("explicit only mode", func(t *testing.T) {
+ out := captureStderr(t, func() {
+ warnInvalidConfigTimezone("BadTZ", timezoneExplicitOnly)
+ })
+ if !strings.Contains(out, "warning") || !strings.Contains(out, "BadTZ") {
+ t.Errorf("expected warning about BadTZ, got %q", out)
+ }
+ if !strings.Contains(out, "ignoring") {
+ t.Errorf("expected 'ignoring' in explicit-only warning, got %q", out)
+ }
+ })
+}
+
+func TestParseTimezoneValue(t *testing.T) {
+ tests := []struct {
+ name string
+ label string
+ value string
+ allowLocal bool
+ wantNil bool
+ wantOK bool
+ wantErr bool
+ }{
+ {"empty value", "test", "", false, true, false, false},
+ {"whitespace only", "test", " ", false, true, false, false},
+ {"local allowed", "test", "local", true, false, true, false},
+ {"local not allowed", "test", "local", false, true, true, true},
+ {"valid timezone", "test", "UTC", false, false, true, false},
+ {"valid timezone America", "test", "America/New_York", false, false, true, false},
+ {"invalid timezone", "test", "Invalid/Zone", false, true, true, true},
+ {"local uppercase", "test", "LOCAL", true, false, true, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ loc, ok, err := parseTimezoneValue(tt.label, tt.value, tt.allowLocal)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseTimezoneValue error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if ok != tt.wantOK {
+ t.Errorf("parseTimezoneValue ok = %v, wantOK %v", ok, tt.wantOK)
+ }
+ if (loc == nil) != tt.wantNil {
+ t.Errorf("parseTimezoneValue loc nil = %v, wantNil %v", loc == nil, tt.wantNil)
+ }
+ })
+ }
+}
+
+func TestResolveOutputLocationHelpers(t *testing.T) {
+ tests := []struct {
+ name string
+ timezone string
+ local bool
+ wantErr bool
+ }{
+ {"local flag", "", true, false},
+ {"valid timezone", "UTC", false, false},
+ {"empty returns local", "", false, false},
+ {"invalid timezone", "Invalid/Zone", false, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ loc, err := resolveOutputLocation(tt.timezone, tt.local)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("resolveOutputLocation error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && loc == nil {
+ t.Error("resolveOutputLocation returned nil location")
+ }
+ })
+ }
+}
+
+func TestGetConfiguredTimezoneHelpers(t *testing.T) {
+ tests := []struct {
+ name string
+ timezone string
+ wantNil bool
+ wantErr bool
+ }{
+ {"empty returns nil", "", true, false},
+ {"valid timezone", "UTC", false, false},
+ {"local timezone", "local", false, false},
+ {"invalid timezone", "Invalid/Zone", true, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ loc, err := getConfiguredTimezone(tt.timezone)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("getConfiguredTimezone error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if (loc == nil) != tt.wantNil {
+ t.Errorf("getConfiguredTimezone loc nil = %v, wantNil %v", loc == nil, tt.wantNil)
+ }
+ })
+ }
+}
+
+// =============================================================================
+// Additional Completion Tests for Coverage
+// =============================================================================
+
+func TestExpectsFlagValue(t *testing.T) {
+ node := &completionNode{
+ flags: map[string]completionFlag{
+ "--account": {takesValue: true},
+ "--verbose": {takesValue: false},
+ },
+ }
+
+ tests := []struct {
+ name string
+ cword int
+ words []string
+ start int
+ expect bool
+ }{
+ {"cword at start", 0, []string{"gog"}, 0, false},
+ {"cword below start", 0, []string{"gog", "auth"}, 1, false},
+ {"previous is flag with value", 2, []string{"gog", "--account", ""}, 1, true},
+ {"previous is bool flag", 2, []string{"gog", "--verbose", ""}, 1, false},
+ {"previous is flag=value", 2, []string{"gog", "--account=test", ""}, 1, true},
+ {"previous is not flag", 2, []string{"gog", "auth", ""}, 1, false},
+ {"cword exceeds words", 5, []string{"gog", "auth"}, 1, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := expectsFlagValue(node, tt.cword, tt.words, tt.start)
+ if got != tt.expect {
+ t.Errorf("expectsFlagValue(node, %d, %v, %d) = %v, want %v",
+ tt.cword, tt.words, tt.start, got, tt.expect)
+ }
+ })
+ }
+}
+
+func TestAdvanceCompletionNode(t *testing.T) {
+ // Build a simple test tree
+ childNode := &completionNode{
+ children: make(map[string]*completionNode),
+ flags: map[string]completionFlag{
+ "--list": {takesValue: false},
+ },
+ }
+ root := &completionNode{
+ children: map[string]*completionNode{
+ "auth": childNode,
+ },
+ flags: map[string]completionFlag{
+ "--account": {takesValue: true},
+ "--verbose": {takesValue: false},
+ },
+ }
+
+ tests := []struct {
+ name string
+ words []string
+ start int
+ cword int
+ wantSameAsRoot bool
+ wantTerminator int
+ wantNeedsValue bool
+ }{
+ {
+ name: "subcommand traversal",
+ words: []string{"gog", "auth", ""},
+ start: 1,
+ cword: 2,
+ wantSameAsRoot: false,
+ wantTerminator: -1,
+ wantNeedsValue: false,
+ },
+ {
+ name: "terminator stops traversal",
+ words: []string{"gog", "auth", "--", "extra"},
+ start: 1,
+ cword: 3,
+ wantSameAsRoot: false,
+ wantTerminator: 2,
+ wantNeedsValue: false,
+ },
+ {
+ name: "flag with value inline",
+ words: []string{"gog", "--account=test", "auth"},
+ start: 1,
+ cword: 2,
+ wantSameAsRoot: true, // After processing --account=test, we're back at root and "auth" isn't processed yet
+ wantTerminator: -1,
+ wantNeedsValue: false,
+ },
+ {
+ name: "flag expecting value",
+ words: []string{"gog", "--account", ""},
+ start: 1,
+ cword: 2,
+ wantSameAsRoot: true,
+ wantTerminator: -1,
+ wantNeedsValue: true,
+ },
+ {
+ name: "bool flag continues",
+ words: []string{"gog", "--verbose", "auth"},
+ start: 1,
+ cword: 2,
+ wantSameAsRoot: true, // Bool flag is skipped and "auth" isn't processed before cword
+ wantTerminator: -1,
+ wantNeedsValue: false,
+ },
+ {
+ name: "unknown command skipped",
+ words: []string{"gog", "unknown", ""},
+ start: 1,
+ cword: 2,
+ wantSameAsRoot: true,
+ wantTerminator: -1,
+ wantNeedsValue: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ node, terminatorIdx, needsValue := advanceCompletionNode(root, tt.words, tt.start, tt.cword)
+ if terminatorIdx != tt.wantTerminator {
+ t.Errorf("terminatorIndex = %d, want %d", terminatorIdx, tt.wantTerminator)
+ }
+ if needsValue != tt.wantNeedsValue {
+ t.Errorf("needsValue = %v, want %v", needsValue, tt.wantNeedsValue)
+ }
+ if tt.wantSameAsRoot && node != root {
+ t.Error("expected to stay at root node")
+ }
+ if !tt.wantSameAsRoot && node == root {
+ t.Error("expected to advance from root node")
+ }
+ })
+ }
+}
+
+func TestNegatedFlagName(t *testing.T) {
+ tests := []struct {
+ name string
+ negatable string
+ flagName string
+ want string
+ }{
+ // These test the negatedFlagName logic through the actual kong Flag
+ // but we can test the logic pattern with simplified inputs
+ }
+
+ // The function uses *kong.Flag which is complex to construct
+ // We'll test the pattern through the addFlagTokens function indirectly
+ // by observing behavior in completion tests
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _ = tt // placeholder
+ })
+ }
+}
+
+func TestAddFlag(t *testing.T) {
+ flags := make(map[string]completionFlag)
+
+ // Test empty token is ignored
+ addFlag(flags, "", false)
+ if len(flags) != 0 {
+ t.Error("empty token should not be added")
+ }
+
+ // Test normal add
+ addFlag(flags, "--test", true)
+ if _, ok := flags["--test"]; !ok {
+ t.Error("flag --test should be added")
+ }
+ if !flags["--test"].takesValue {
+ t.Error("flag --test should take value")
+ }
+
+ // Test duplicate is not overwritten
+ addFlag(flags, "--test", false)
+ if !flags["--test"].takesValue {
+ t.Error("duplicate flag should not overwrite existing")
+ }
+}
+
+func TestCompleteWordsWithFlags(t *testing.T) {
+ // Test completing flags
+ completions, err := completeWords(1, []string{"gog", "--"})
+ if err != nil {
+ t.Fatalf("completeWords error: %v", err)
+ }
+
+ // Should return some flag completions starting with --
+ foundFlag := false
+ for _, c := range completions {
+ if strings.HasPrefix(c, "--") {
+ foundFlag = true
+ break
+ }
+ }
+ if !foundFlag && len(completions) > 0 {
+ t.Error("expected at least one flag completion starting with --")
+ }
+}
+
+func TestCompleteWordsWithSubcommand(t *testing.T) {
+ // Test completing after navigating to a subcommand
+ completions, err := completeWords(2, []string{"gog", "auth", ""})
+ if err != nil {
+ t.Fatalf("completeWords error: %v", err)
+ }
+
+ // Should return subcommand completions for auth (like list, add, etc)
+ // At minimum it should not error
+ _ = completions
+}
diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go
index ac27a783..0258af67 100644
--- a/internal/cmd/testutil_test.go
+++ b/internal/cmd/testutil_test.go
@@ -1,5 +1,10 @@
package cmd
+// NOTE: Command tests in this package MUST NOT use t.Parallel() because they
+// rely on replacing package-level service factory variables (e.g.
+// newAlertCenterService, newAdminDirectoryService) with test stubs. Running
+// tests concurrently would cause data races on these shared variables.
+
import (
"context"
"encoding/json"
@@ -14,6 +19,8 @@ import (
"github.com/alecthomas/kong"
"github.com/steipete/gogcli/internal/googleauth"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
)
// withPrimaryCalendar wraps an http.Handler to also respond to primary calendar requests
@@ -70,6 +77,15 @@ func pickNonLocalTimezone(t *testing.T) string {
return pickTimezoneExcluding(t, time.Local.String(), "local")
}
+func testContextJSON(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
+}
+
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
diff --git a/internal/cmd/todrive_helpers.go b/internal/cmd/todrive_helpers.go
new file mode 100644
index 00000000..2931ea86
--- /dev/null
+++ b/internal/cmd/todrive_helpers.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/todrive"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type ToDriveFlags struct {
+ ToDrive bool `name:"todrive" help:"Output to Google Sheets"`
+ SheetName string `name:"todrive-sheet" help:"Sheet name"`
+ Folder string `name:"todrive-folder" help:"Drive folder ID"`
+ Timestamp bool `name:"todrive-timestamp" help:"Append timestamp to sheet name"`
+ Notify string `name:"todrive-notify" help:"Email to notify"`
+ Update bool `name:"todrive-update" help:"Update existing sheet"`
+}
+
+func (t ToDriveFlags) enabled() bool { return t.ToDrive }
+
+func writeToDrive(ctx context.Context, flags *RootFlags, title string, headers []string, rows [][]string, opts ToDriveFlags) (bool, error) {
+ if !opts.enabled() {
+ return false, nil
+ }
+ account, err := requireAccount(flags)
+ if err != nil {
+ return true, err
+ }
+
+ writer, err := todrive.New(ctx, account)
+ if err != nil {
+ return true, err
+ }
+
+ result, err := writer.Write(ctx, headers, rows, todrive.Options{
+ SheetName: title,
+ FolderID: opts.Folder,
+ Timestamp: opts.Timestamp,
+ Notify: opts.Notify,
+ Update: opts.Update,
+ })
+ if err != nil {
+ return true, err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return true, outfmt.WriteJSON(os.Stdout, map[string]any{
+ "sheetId": result.SpreadsheetID,
+ "sheetName": result.SheetName,
+ "url": result.URL,
+ })
+ }
+
+ u := ui.FromContext(ctx)
+ if u != nil {
+ u.Out().Printf("Saved to Google Sheets: %s\n", result.URL)
+ }
+ return true, nil
+}
+
+func toDriveTitle(base string, opts ToDriveFlags) string {
+ if opts.SheetName != "" {
+ return opts.SheetName
+ }
+ return base
+}
+
+func toDriveRow(values ...string) []string {
+ row := make([]string, len(values))
+ copy(row, values)
+ return row
+}
+
+func toDriveBool(value bool) string {
+ if value {
+ return strTrue
+ }
+ return strFalse
+}
+
+func toDriveNumber(value int64) string {
+ return fmt.Sprintf("%d", value)
+}
diff --git a/internal/cmd/transfer.go b/internal/cmd/transfer.go
new file mode 100644
index 00000000..c455db2a
--- /dev/null
+++ b/internal/cmd/transfer.go
@@ -0,0 +1,311 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ datatransfer "google.golang.org/api/admin/datatransfer/v1"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newDataTransferService = googleapi.NewDataTransfer
+
+type TransferCmd struct {
+ List TransferListCmd `cmd:"" name:"list" aliases:"ls" help:"List data transfers"`
+ Get TransferGetCmd `cmd:"" name:"get" help:"Get data transfer"`
+ Create TransferCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create data transfer"`
+ Applications TransferApplicationsCmd `cmd:"" name:"applications" help:"List transferable applications"`
+}
+
+type TransferListCmd struct {
+ OldOwner string `name:"old-owner" help:"Filter by old owner email"`
+ NewOwner string `name:"new-owner" help:"Filter by new owner email"`
+ Status string `name:"status" help:"Filter by status"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *TransferListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newDataTransferService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Transfers.List().CustomerId(adminCustomerID())
+ if c.OldOwner != "" {
+ call = call.OldOwnerUserId(c.OldOwner)
+ }
+ if c.NewOwner != "" {
+ call = call.NewOwnerUserId(c.NewOwner)
+ }
+ if c.Status != "" {
+ call = call.Status(c.Status)
+ }
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list transfers: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.DataTransfers) == 0 {
+ u.Err().Println("No transfers found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "TRANSFER ID\tOLD OWNER\tNEW OWNER\tSTATUS\tAPPLICATIONS")
+ for _, transfer := range resp.DataTransfers {
+ if transfer == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n",
+ sanitizeTab(transfer.Id),
+ sanitizeTab(transfer.OldOwnerUserId),
+ sanitizeTab(transfer.NewOwnerUserId),
+ sanitizeTab(transfer.OverallTransferStatusCode),
+ len(transfer.ApplicationDataTransfers),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type TransferGetCmd struct {
+ TransferID string `arg:"" name:"transfer-id" help:"Transfer ID"`
+}
+
+func (c *TransferGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ transferID := strings.TrimSpace(c.TransferID)
+ if transferID == "" {
+ return usage("transfer ID is required")
+ }
+
+ svc, err := newDataTransferService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ transfer, err := svc.Transfers.Get(transferID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get transfer %s: %w", transferID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, transfer)
+ }
+
+ u.Out().Printf("ID: %s\n", transfer.Id)
+ u.Out().Printf("Old Owner: %s\n", transfer.OldOwnerUserId)
+ u.Out().Printf("New Owner: %s\n", transfer.NewOwnerUserId)
+ u.Out().Printf("Status: %s\n", transfer.OverallTransferStatusCode)
+ if len(transfer.ApplicationDataTransfers) > 0 {
+ u.Out().Printf("Apps: %d\n", len(transfer.ApplicationDataTransfers))
+ }
+ return nil
+}
+
+type TransferCreateCmd struct {
+ OldOwner string `name:"old-owner" help:"Old owner email" required:""`
+ NewOwner string `name:"new-owner" help:"New owner email" required:""`
+ Application string `name:"application" help:"Application ID" required:""`
+ Parameters string `name:"parameters" help:"Transfer parameters (JSON map or key=value list)"`
+}
+
+func (c *TransferCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ oldOwner := strings.TrimSpace(c.OldOwner)
+ newOwner := strings.TrimSpace(c.NewOwner)
+ if oldOwner == "" || newOwner == "" {
+ return usage("--old-owner and --new-owner are required")
+ }
+
+ appID, err := strconv.ParseInt(strings.TrimSpace(c.Application), 10, 64)
+ if err != nil {
+ return usage("--application must be a numeric application ID")
+ }
+
+ params, err := parseTransferParams(c.Parameters)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newDataTransferService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ transfer := &datatransfer.DataTransfer{
+ OldOwnerUserId: oldOwner,
+ NewOwnerUserId: newOwner,
+ ApplicationDataTransfers: []*datatransfer.ApplicationDataTransfer{
+ {
+ ApplicationId: appID,
+ ApplicationTransferParams: params,
+ },
+ },
+ }
+
+ created, err := svc.Transfers.Insert(transfer).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create transfer: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u.Out().Printf("Created transfer: %s\n", created.Id)
+ return nil
+}
+
+type TransferApplicationsCmd struct{}
+
+func (c *TransferApplicationsCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newDataTransferService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Applications.List().Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list applications: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Applications) == 0 {
+ u.Err().Println("No applications found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "ID\tNAME\tPARAMS")
+ for _, app := range resp.Applications {
+ if app == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%d\t%s\t%d\n",
+ app.Id,
+ sanitizeTab(app.Name),
+ len(app.TransferParams),
+ )
+ }
+ return nil
+}
+
+func parseTransferParams(input string) ([]*datatransfer.ApplicationTransferParam, error) {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return nil, nil
+ }
+
+ payload, err := readValueOrFile(trimmed)
+ if err != nil {
+ return nil, err
+ }
+ payload = strings.TrimSpace(payload)
+ if payload == "" {
+ return nil, nil
+ }
+
+ if strings.HasPrefix(payload, "[") {
+ var params []*datatransfer.ApplicationTransferParam
+ if err := json.Unmarshal([]byte(payload), ¶ms); err == nil {
+ return params, nil
+ }
+ }
+
+ if strings.HasPrefix(payload, "{") {
+ var paramsMap map[string][]string
+ if err := json.Unmarshal([]byte(payload), ¶msMap); err == nil {
+ return transferParamsFromMap(paramsMap), nil
+ }
+ var paramsSimple map[string]string
+ if err := json.Unmarshal([]byte(payload), ¶msSimple); err == nil {
+ paramsMap = make(map[string][]string, len(paramsSimple))
+ for key, value := range paramsSimple {
+ paramsMap[key] = []string{value}
+ }
+ return transferParamsFromMap(paramsMap), nil
+ }
+ }
+
+ pairs := splitCSV(payload)
+ paramsMap := make(map[string][]string, len(pairs))
+ for _, pair := range pairs {
+ parts := strings.SplitN(pair, "=", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid parameter %q (expected key=value)", pair)
+ }
+ key := strings.TrimSpace(parts[0])
+ if key == "" {
+ return nil, fmt.Errorf("invalid parameter %q (empty key)", pair)
+ }
+ valuePart := strings.TrimSpace(parts[1])
+ if valuePart == "" {
+ return nil, fmt.Errorf("invalid parameter %q (empty value)", pair)
+ }
+ values := strings.Split(valuePart, "|")
+ for i := range values {
+ values[i] = strings.TrimSpace(values[i])
+ }
+ paramsMap[key] = values
+ }
+
+ return transferParamsFromMap(paramsMap), nil
+}
+
+func transferParamsFromMap(paramsMap map[string][]string) []*datatransfer.ApplicationTransferParam {
+ params := make([]*datatransfer.ApplicationTransferParam, 0, len(paramsMap))
+ for key, values := range paramsMap {
+ params = append(params, &datatransfer.ApplicationTransferParam{
+ Key: key,
+ Value: values,
+ })
+ }
+ return params
+}
diff --git a/internal/cmd/transfer_test.go b/internal/cmd/transfer_test.go
new file mode 100644
index 00000000..314ac5e3
--- /dev/null
+++ b/internal/cmd/transfer_test.go
@@ -0,0 +1,760 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ datatransfer "google.golang.org/api/admin/datatransfer/v1"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+func TestTransferListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dataTransfers": []map[string]any{
+ {"id": "t1", "oldOwnerUserId": "old@example.com", "newOwnerUserId": "new@example.com", "overallTransferStatusCode": "completed"},
+ },
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "t1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestTransferCreateCmd(t *testing.T) {
+ var gotOld string
+ var gotApp string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ OldOwnerUserId string `json:"oldOwnerUserId"`
+ ApplicationDataTransfers []struct {
+ ApplicationId string `json:"applicationId"`
+ } `json:"applicationDataTransfers"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ gotOld = payload.OldOwnerUserId
+ if len(payload.ApplicationDataTransfers) > 0 {
+ gotApp = payload.ApplicationDataTransfers[0].ApplicationId
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "t2",
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferCreateCmd{OldOwner: "old@example.com", NewOwner: "new@example.com", Application: "4350700"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotOld != "old@example.com" {
+ t.Fatalf("unexpected old owner: %q", gotOld)
+ }
+ if gotApp != "4350700" {
+ t.Fatalf("unexpected app id: %s", gotApp)
+ }
+ if !strings.Contains(out, "Created transfer") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func stubDataTransfer(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newDataTransferService
+ svc, err := datatransfer.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new datatransfer service: %v", err)
+ }
+ newDataTransferService = func(context.Context, string) (*datatransfer.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newDataTransferService = orig
+ srv.Close()
+ })
+}
+
+func TestTransferListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dataTransfers": []map[string]any{
+ {
+ "id": "t1",
+ "oldOwnerUserId": "old@example.com",
+ "newOwnerUserId": "new@example.com",
+ "overallTransferStatusCode": "completed",
+ "applicationDataTransfers": []map[string]any{{"applicationId": "123"}},
+ },
+ },
+ "nextPageToken": "npt",
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferListCmd{}
+
+ ctx := testContextTransfer(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result struct {
+ DataTransfers []struct {
+ ID string `json:"id"`
+ OldOwnerUserId string `json:"oldOwnerUserId"`
+ NewOwnerUserId string `json:"newOwnerUserId"`
+ OverallTransferStatusCode string `json:"overallTransferStatusCode"`
+ } `json:"dataTransfers"`
+ NextPageToken string `json:"nextPageToken"`
+ }
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("json parse: %v\nout=%q", err, out)
+ }
+ if len(result.DataTransfers) != 1 {
+ t.Fatalf("expected 1 transfer, got %d", len(result.DataTransfers))
+ }
+ if result.DataTransfers[0].ID != "t1" {
+ t.Fatalf("expected transfer ID t1, got %q", result.DataTransfers[0].ID)
+ }
+ if result.NextPageToken != "npt" {
+ t.Fatalf("expected next page token npt, got %q", result.NextPageToken)
+ }
+}
+
+func TestTransferListCmd_WithFilters(t *testing.T) {
+ var gotOldOwner, gotNewOwner, gotStatus string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ gotOldOwner = r.URL.Query().Get("oldOwnerUserId")
+ gotNewOwner = r.URL.Query().Get("newOwnerUserId")
+ gotStatus = r.URL.Query().Get("status")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dataTransfers": []map[string]any{},
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferListCmd{
+ OldOwner: "old@example.com",
+ NewOwner: "new@example.com",
+ Status: "completed",
+ Max: 50,
+ }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ _ = cmd.Run(testContextTransfer(t), flags)
+ })
+ })
+
+ if gotOldOwner != "old@example.com" {
+ t.Fatalf("expected old owner filter, got %q", gotOldOwner)
+ }
+ if gotNewOwner != "new@example.com" {
+ t.Fatalf("expected new owner filter, got %q", gotNewOwner)
+ }
+ if gotStatus != "completed" {
+ t.Fatalf("expected status filter, got %q", gotStatus)
+ }
+}
+
+func TestTransferListCmd_EmptyList(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dataTransfers": []map[string]any{},
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferListCmd{}
+
+ stderr := captureStderr(t, func() {
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+ })
+
+ if !strings.Contains(stderr, "No transfers found") {
+ t.Fatalf("expected 'No transfers found' in stderr, got %q", stderr)
+ }
+}
+
+func TestTransferListCmd_WithPagination(t *testing.T) {
+ var gotPageToken string
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ gotPageToken = r.URL.Query().Get("pageToken")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "dataTransfers": []map[string]any{
+ {"id": "t1"},
+ },
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferListCmd{Page: "page123"}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if gotPageToken != "page123" {
+ t.Fatalf("expected page token 'page123', got %q", gotPageToken)
+ }
+}
+
+func TestTransferGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers/t123") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "t123",
+ "oldOwnerUserId": "old@example.com",
+ "newOwnerUserId": "new@example.com",
+ "overallTransferStatusCode": "completed",
+ "applicationDataTransfers": []map[string]any{{"applicationId": "456"}},
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferGetCmd{TransferID: "t123"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "t123") {
+ t.Fatalf("expected transfer ID in output, got %q", out)
+ }
+ if !strings.Contains(out, "old@example.com") {
+ t.Fatalf("expected old owner in output, got %q", out)
+ }
+ if !strings.Contains(out, "new@example.com") {
+ t.Fatalf("expected new owner in output, got %q", out)
+ }
+ if !strings.Contains(out, "completed") {
+ t.Fatalf("expected status in output, got %q", out)
+ }
+}
+
+func TestTransferGetCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers/t456") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "t456",
+ "oldOwnerUserId": "old@example.com",
+ "newOwnerUserId": "new@example.com",
+ "overallTransferStatusCode": "inProgress",
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferGetCmd{TransferID: "t456"}
+
+ ctx := testContextTransfer(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result struct {
+ ID string `json:"id"`
+ OldOwnerUserId string `json:"oldOwnerUserId"`
+ NewOwnerUserId string `json:"newOwnerUserId"`
+ OverallTransferStatusCode string `json:"overallTransferStatusCode"`
+ }
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("json parse: %v\nout=%q", err, out)
+ }
+ if result.ID != "t456" {
+ t.Fatalf("expected ID t456, got %q", result.ID)
+ }
+ if result.OverallTransferStatusCode != "inProgress" {
+ t.Fatalf("expected status inProgress, got %q", result.OverallTransferStatusCode)
+ }
+}
+
+func TestTransferGetCmd_EmptyID(t *testing.T) {
+ stubDataTransfer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferGetCmd{TransferID: " "}
+
+ err := cmd.Run(testContextTransfer(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty transfer ID")
+ }
+ if !strings.Contains(err.Error(), "required") {
+ t.Fatalf("expected 'required' in error, got %v", err)
+ }
+}
+
+func TestTransferCreateCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "t789",
+ "oldOwnerUserId": "old@example.com",
+ "newOwnerUserId": "new@example.com",
+ "overallTransferStatusCode": "pending",
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferCreateCmd{
+ OldOwner: "old@example.com",
+ NewOwner: "new@example.com",
+ Application: "12345",
+ }
+
+ ctx := testContextTransfer(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("json parse: %v\nout=%q", err, out)
+ }
+ if result.ID != "t789" {
+ t.Fatalf("expected ID t789, got %q", result.ID)
+ }
+}
+
+func TestTransferCreateCmd_MissingOwners(t *testing.T) {
+ stubDataTransfer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferCreateCmd{
+ OldOwner: "",
+ NewOwner: "new@example.com",
+ Application: "12345",
+ }
+
+ err := cmd.Run(testContextTransfer(t), flags)
+ if err == nil {
+ t.Fatal("expected error for missing old owner")
+ }
+ if !strings.Contains(err.Error(), "required") {
+ t.Fatalf("expected 'required' in error, got %v", err)
+ }
+}
+
+func TestTransferCreateCmd_InvalidApplication(t *testing.T) {
+ stubDataTransfer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferCreateCmd{
+ OldOwner: "old@example.com",
+ NewOwner: "new@example.com",
+ Application: "not-a-number",
+ }
+
+ err := cmd.Run(testContextTransfer(t), flags)
+ if err == nil {
+ t.Fatal("expected error for invalid application ID")
+ }
+ if !strings.Contains(err.Error(), "numeric") {
+ t.Fatalf("expected 'numeric' in error, got %v", err)
+ }
+}
+
+func TestTransferCreateCmd_WithJSONParams(t *testing.T) {
+ var gotParams []map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ ApplicationDataTransfers []struct {
+ ApplicationTransferParams []map[string]any `json:"applicationTransferParams"`
+ } `json:"applicationDataTransfers"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ if len(payload.ApplicationDataTransfers) > 0 {
+ gotParams = payload.ApplicationDataTransfers[0].ApplicationTransferParams
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "t1"})
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferCreateCmd{
+ OldOwner: "old@example.com",
+ NewOwner: "new@example.com",
+ Application: "12345",
+ Parameters: `{"key1": ["value1", "value2"]}`,
+ }
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if len(gotParams) != 1 {
+ t.Fatalf("expected 1 param, got %d", len(gotParams))
+ }
+}
+
+func TestTransferCreateCmd_WithKeyValueParams(t *testing.T) {
+ var gotParams []map[string]any
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") {
+ http.NotFound(w, r)
+ return
+ }
+ var payload struct {
+ ApplicationDataTransfers []struct {
+ ApplicationTransferParams []map[string]any `json:"applicationTransferParams"`
+ } `json:"applicationDataTransfers"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&payload)
+ if len(payload.ApplicationDataTransfers) > 0 {
+ gotParams = payload.ApplicationDataTransfers[0].ApplicationTransferParams
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "t1"})
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferCreateCmd{
+ OldOwner: "old@example.com",
+ NewOwner: "new@example.com",
+ Application: "12345",
+ Parameters: "key1=value1,key2=value2",
+ }
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if len(gotParams) != 2 {
+ t.Fatalf("expected 2 params, got %d", len(gotParams))
+ }
+}
+
+func TestTransferApplicationsCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/applications") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "applications": []map[string]any{
+ {"id": "100", "name": "Google Drive", "transferParams": []map[string]any{{"key": "param1"}}},
+ {"id": "200", "name": "Google Calendar", "transferParams": []map[string]any{}},
+ },
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferApplicationsCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Google Drive") {
+ t.Fatalf("expected Google Drive in output, got %q", out)
+ }
+ if !strings.Contains(out, "Google Calendar") {
+ t.Fatalf("expected Google Calendar in output, got %q", out)
+ }
+}
+
+func TestTransferApplicationsCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/applications") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "applications": []map[string]any{
+ {"id": "100", "name": "Google Drive"},
+ },
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferApplicationsCmd{}
+
+ ctx := testContextTransfer(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ var result struct {
+ Applications []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ } `json:"applications"`
+ }
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ t.Fatalf("json parse: %v\nout=%q", err, out)
+ }
+ if len(result.Applications) != 1 {
+ t.Fatalf("expected 1 application, got %d", len(result.Applications))
+ }
+ if result.Applications[0].Name != "Google Drive" {
+ t.Fatalf("expected Google Drive, got %q", result.Applications[0].Name)
+ }
+}
+
+func TestTransferApplicationsCmd_EmptyList(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/applications") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "applications": []map[string]any{},
+ })
+ })
+ stubDataTransfer(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &TransferApplicationsCmd{}
+
+ stderr := captureStderr(t, func() {
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+ })
+
+ if !strings.Contains(stderr, "No applications found") {
+ t.Fatalf("expected 'No applications found' in stderr, got %q", stderr)
+ }
+}
+
+func TestParseTransferParams_EmptyInput(t *testing.T) {
+ params, err := parseTransferParams("")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if params != nil {
+ t.Fatalf("expected nil params, got %v", params)
+ }
+}
+
+func TestParseTransferParams_SimpleJSON(t *testing.T) {
+ params, err := parseTransferParams(`{"key1": "value1"}`)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(params) != 1 {
+ t.Fatalf("expected 1 param, got %d", len(params))
+ }
+ if params[0].Key != "key1" {
+ t.Fatalf("expected key1, got %q", params[0].Key)
+ }
+}
+
+func TestParseTransferParams_ArrayJSON(t *testing.T) {
+ params, err := parseTransferParams(`{"key1": ["v1", "v2"]}`)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(params) != 1 {
+ t.Fatalf("expected 1 param, got %d", len(params))
+ }
+ if len(params[0].Value) != 2 {
+ t.Fatalf("expected 2 values, got %d", len(params[0].Value))
+ }
+}
+
+func TestParseTransferParams_KeyValuePairs(t *testing.T) {
+ params, err := parseTransferParams("key1=value1,key2=value2")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(params) != 2 {
+ t.Fatalf("expected 2 params, got %d", len(params))
+ }
+}
+
+func TestParseTransferParams_PipeDelimitedValues(t *testing.T) {
+ params, err := parseTransferParams("key1=v1|v2|v3")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(params) != 1 {
+ t.Fatalf("expected 1 param, got %d", len(params))
+ }
+ if len(params[0].Value) != 3 {
+ t.Fatalf("expected 3 values, got %d", len(params[0].Value))
+ }
+}
+
+func TestParseTransferParams_InvalidKeyValue(t *testing.T) {
+ _, err := parseTransferParams("invalid-no-equals")
+ if err == nil {
+ t.Fatal("expected error for invalid key=value")
+ }
+ if !strings.Contains(err.Error(), "expected key=value") {
+ t.Fatalf("expected 'expected key=value' in error, got %v", err)
+ }
+}
+
+func TestParseTransferParams_EmptyKey(t *testing.T) {
+ _, err := parseTransferParams("=value")
+ if err == nil {
+ t.Fatal("expected error for empty key")
+ }
+ if !strings.Contains(err.Error(), "empty key") {
+ t.Fatalf("expected 'empty key' in error, got %v", err)
+ }
+}
+
+func TestParseTransferParams_EmptyValue(t *testing.T) {
+ _, err := parseTransferParams("key=")
+ if err == nil {
+ t.Fatal("expected error for empty value")
+ }
+ if !strings.Contains(err.Error(), "empty value") {
+ t.Fatalf("expected 'empty value' in error, got %v", err)
+ }
+}
+
+func TestTransferParamsFromMap(t *testing.T) {
+ paramsMap := map[string][]string{
+ "key1": {"value1"},
+ "key2": {"v2a", "v2b"},
+ }
+ params := transferParamsFromMap(paramsMap)
+ if len(params) != 2 {
+ t.Fatalf("expected 2 params, got %d", len(params))
+ }
+}
+
+func testContextTransfer(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func testContextWithStdoutTransfer(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
diff --git a/internal/cmd/users.go b/internal/cmd/users.go
new file mode 100644
index 00000000..481cd5c8
--- /dev/null
+++ b/internal/cmd/users.go
@@ -0,0 +1,112 @@
+package cmd
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "strings"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+)
+
+var newAdminDirectory = googleapi.NewAdminDirectory
+
+// UsersCmd is the top-level users command.
+type UsersCmd struct {
+ List UsersListCmd `cmd:"" name:"list" aliases:"ls" help:"List users in domain"`
+ Get UsersGetCmd `cmd:"" name:"get" aliases:"info" help:"Get user details"`
+ Create UsersCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create a new user"`
+ Update UsersUpdateCmd `cmd:"" name:"update" help:"Update user attributes"`
+ Delete UsersDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a user"`
+ Suspend UsersSuspendCmd `cmd:"" name:"suspend" help:"Suspend a user"`
+ Unsuspend UsersUnsuspendCmd `cmd:"" name:"unsuspend" aliases:"activate" help:"Unsuspend a user"`
+ Password UsersPasswordCmd `cmd:"" name:"password" aliases:"passwd" help:"Reset user password"`
+ Signout UsersSignoutCmd `cmd:"" name:"signout" help:"Sign out user from all sessions"`
+ TurnOff2SV UsersTurnOff2SVCmd `cmd:"" name:"turnoff2sv" aliases:"disable2fa" help:"Turn off 2-step verification"`
+ BackupCodes UsersBackupCodesCmd `cmd:"" name:"backupcodes" aliases:"verificationcodes" help:"Manage backup verification codes"`
+ ASPs UsersASPsCmd `cmd:"" name:"asps" aliases:"apppasswords" help:"Manage app-specific passwords"`
+ Tokens UsersTokensCmd `cmd:"" name:"tokens" help:"Manage user tokens"`
+ Count UsersCountCmd `cmd:"" name:"count" help:"Count users by org unit"`
+}
+
+func extractDomain(email string) string {
+ if idx := strings.LastIndex(email, "@"); idx >= 0 {
+ return email[idx+1:]
+ }
+ return email
+}
+
+func generatePassword(length int) (string, error) {
+ if length < 8 {
+ length = 8
+ }
+ const lower = "abcdefghijklmnopqrstuvwxyz"
+ const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ const digits = "0123456789"
+ const special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
+ const all = lower + upper + digits + special
+
+ sets := []string{lower, upper, digits, special}
+ b := make([]byte, length)
+ for i, set := range sets {
+ ch, err := randChar(set)
+ if err != nil {
+ return "", err
+ }
+ b[i] = ch
+ }
+ for i := len(sets); i < length; i++ {
+ ch, err := randChar(all)
+ if err != nil {
+ return "", err
+ }
+ b[i] = ch
+ }
+
+ for i := len(b) - 1; i > 0; i-- {
+ j, err := randInt(i + 1)
+ if err != nil {
+ return "", err
+ }
+ b[i], b[j] = b[j], b[i]
+ }
+
+ return string(b), nil
+}
+
+func randChar(set string) (byte, error) {
+ if len(set) == 0 {
+ return 0, fmt.Errorf("empty character set")
+ }
+ idx, err := randInt(len(set))
+ if err != nil {
+ return 0, err
+ }
+ return set[idx], nil
+}
+
+func normalizeUserHashFunction(value string) (string, error) {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "md5":
+ return "MD5", nil
+ case "sha-1", "sha1":
+ return "SHA-1", nil
+ case "crypt":
+ return "crypt", nil
+ case "":
+ return "", nil
+ default:
+ return "", usage("invalid --hash-function (expected MD5, SHA-1, crypt)")
+ }
+}
+
+func randInt(maxVal int) (int, error) {
+ if maxVal <= 0 {
+ return 0, fmt.Errorf("invalid max %d", maxVal)
+ }
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(maxVal)))
+ if err != nil {
+ return 0, err
+ }
+ return int(n.Int64()), nil
+}
diff --git a/internal/cmd/users_2fa.go b/internal/cmd/users_2fa.go
new file mode 100644
index 00000000..1fa8bf9b
--- /dev/null
+++ b/internal/cmd/users_2fa.go
@@ -0,0 +1,136 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersTurnOff2SVCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+}
+
+func (c *UsersTurnOff2SVCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("turn off 2-step verification for %s", c.User)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.TwoStepVerification.TurnOff(c.User).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("turn off 2SV for %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Turned off 2-step verification for: %s\n", c.User)
+ return nil
+}
+
+type UsersBackupCodesCmd struct {
+ List UsersBackupCodesListCmd `cmd:"" name:"list" aliases:"show" help:"List backup codes"`
+ Generate UsersBackupCodesGenerateCmd `cmd:"" name:"generate" aliases:"create" help:"Generate new backup codes"`
+ Delete UsersBackupCodesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete all backup codes"`
+}
+
+type UsersBackupCodesListCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+}
+
+func (c *UsersBackupCodesListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ codes, err := svc.VerificationCodes.List(c.User).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list backup codes for %s: %w", c.User, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, codes)
+ }
+
+ if len(codes.Items) == 0 {
+ u.Err().Println("No backup codes found")
+ return nil
+ }
+
+ u.Out().Printf("Backup codes for %s:\n", c.User)
+ for _, code := range codes.Items {
+ u.Out().Printf(" %s\n", code.VerificationCode)
+ }
+
+ return nil
+}
+
+type UsersBackupCodesGenerateCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+}
+
+func (c *UsersBackupCodesGenerateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.VerificationCodes.Generate(c.User).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("generate backup codes for %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Generated new backup codes for: %s\n", c.User)
+ u.Out().Println("Use 'gog users backupcodes list' to view the new codes")
+
+ return nil
+}
+
+type UsersBackupCodesDeleteCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+}
+
+func (c *UsersBackupCodesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete all backup codes for %s", c.User)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.VerificationCodes.Invalidate(c.User).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete backup codes for %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Deleted all backup codes for: %s\n", c.User)
+ return nil
+}
diff --git a/internal/cmd/users_advanced_test.go b/internal/cmd/users_advanced_test.go
new file mode 100644
index 00000000..ccbd53f7
--- /dev/null
+++ b/internal/cmd/users_advanced_test.go
@@ -0,0 +1,1323 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+)
+
+// ====================
+// users.go helper functions
+// ====================
+
+func TestGeneratePassword(t *testing.T) {
+ t.Run("generates password with minimum length", func(t *testing.T) {
+ pwd, err := generatePassword(5)
+ if err != nil {
+ t.Fatalf("generatePassword(5): %v", err)
+ }
+ if len(pwd) < 8 {
+ t.Errorf("password should be at least 8 chars, got %d", len(pwd))
+ }
+ })
+
+ t.Run("generates password with specified length", func(t *testing.T) {
+ pwd, err := generatePassword(16)
+ if err != nil {
+ t.Fatalf("generatePassword(16): %v", err)
+ }
+ if len(pwd) != 16 {
+ t.Errorf("expected 16 chars, got %d", len(pwd))
+ }
+ })
+
+ t.Run("contains required character types", func(t *testing.T) {
+ pwd, err := generatePassword(20)
+ if err != nil {
+ t.Fatalf("generatePassword(20): %v", err)
+ }
+
+ hasLower := strings.ContainsAny(pwd, "abcdefghijklmnopqrstuvwxyz")
+ hasUpper := strings.ContainsAny(pwd, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+ hasDigit := strings.ContainsAny(pwd, "0123456789")
+ hasSpecial := strings.ContainsAny(pwd, "!@#$%^&*()_+-=[]{}|;:,.<>?")
+
+ if !hasLower {
+ t.Error("password missing lowercase")
+ }
+ if !hasUpper {
+ t.Error("password missing uppercase")
+ }
+ if !hasDigit {
+ t.Error("password missing digit")
+ }
+ if !hasSpecial {
+ t.Error("password missing special character")
+ }
+ })
+}
+
+func TestRandChar(t *testing.T) {
+ t.Run("returns char from set", func(t *testing.T) {
+ set := "abc"
+ ch, err := randChar(set)
+ if err != nil {
+ t.Fatalf("randChar: %v", err)
+ }
+ if !strings.ContainsRune(set, rune(ch)) {
+ t.Errorf("char %c not in set %s", ch, set)
+ }
+ })
+
+ t.Run("empty set returns error", func(t *testing.T) {
+ _, err := randChar("")
+ if err == nil {
+ t.Error("expected error for empty set")
+ }
+ })
+}
+
+func TestRandInt(t *testing.T) {
+ t.Run("returns value in range", func(t *testing.T) {
+ for i := 0; i < 100; i++ {
+ n, err := randInt(10)
+ if err != nil {
+ t.Fatalf("randInt: %v", err)
+ }
+ if n < 0 || n >= 10 {
+ t.Errorf("value %d out of range [0, 10)", n)
+ }
+ }
+ })
+
+ t.Run("zero max returns error", func(t *testing.T) {
+ _, err := randInt(0)
+ if err == nil {
+ t.Error("expected error for max=0")
+ }
+ })
+
+ t.Run("negative max returns error", func(t *testing.T) {
+ _, err := randInt(-1)
+ if err == nil {
+ t.Error("expected error for max=-1")
+ }
+ })
+}
+
+func TestNormalizeUserHashFunction(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ wantErr bool
+ }{
+ {"md5", "MD5", false},
+ {"MD5", "MD5", false},
+ {"sha-1", "SHA-1", false},
+ {"SHA-1", "SHA-1", false},
+ {"sha1", "SHA-1", false},
+ {"SHA1", "SHA-1", false},
+ {"crypt", "crypt", false},
+ {"CRYPT", "crypt", false},
+ {"", "", false},
+ {" md5 ", "MD5", false},
+ {"invalid", "", true},
+ {"bcrypt", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := normalizeUserHashFunction(tt.input)
+ if tt.wantErr && err == nil {
+ t.Error("expected error")
+ }
+ if !tt.wantErr && err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+// ====================
+// users_2fa.go
+// ====================
+
+func TestUsersTurnOff2SVCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/twoStepVerification/turnOff") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &UsersTurnOff2SVCmd{User: "user@example.com"}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestUsersBackupCodesListCmd(t *testing.T) {
+ t.Run("list codes plain", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/verificationCodes") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"verificationCode": "12345678"},
+ {"verificationCode": "87654321"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersBackupCodesListCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "12345678") || !strings.Contains(out, "87654321") {
+ t.Errorf("expected codes in output: %s", out)
+ }
+ })
+
+ t.Run("list codes JSON", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/verificationCodes") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"verificationCode": "12345678"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersBackupCodesListCmd{User: "user@example.com"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "items") {
+ t.Errorf("expected JSON output: %s", out)
+ }
+ })
+
+ t.Run("empty codes", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/verificationCodes") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersBackupCodesListCmd{User: "user@example.com"}
+
+ // Should not error even with empty list
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+}
+
+func TestUsersBackupCodesGenerateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/verificationCodes/generate") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersBackupCodesGenerateCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Generated new backup codes") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+func TestUsersBackupCodesDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/verificationCodes/invalidate") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &UsersBackupCodesDeleteCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted all backup codes") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+// ====================
+// users_asps.go
+// ====================
+
+func TestUsersASPsListCmd(t *testing.T) {
+ t.Run("list ASPs plain", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/asps") {
+ w.Header().Set("Content-Type", "application/json")
+ // Note: creationTime and lastTimeUsed are string-encoded per API spec
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"codeId": 123, "name": "iPhone Mail", "creationTime": "1704067200", "lastTimeUsed": "1704153600"},
+ {"codeId": 456, "name": "Outlook", "creationTime": "1704067200", "lastTimeUsed": "0"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersASPsListCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "iPhone Mail") || !strings.Contains(out, "Outlook") {
+ t.Errorf("expected ASP names in output: %s", out)
+ }
+ })
+
+ t.Run("list ASPs JSON", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/asps") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"codeId": 123, "name": "iPhone Mail"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersASPsListCmd{User: "user@example.com"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "items") {
+ t.Errorf("expected JSON output: %s", out)
+ }
+ })
+
+ t.Run("empty ASPs", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/asps") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersASPsListCmd{User: "user@example.com"}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+}
+
+func TestUsersASPsDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/asps/123") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersASPsDeleteCmd{User: "user@example.com", CodeID: 123}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted app-specific password") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+func TestFormatUnixSeconds(t *testing.T) {
+ tests := []struct {
+ name string
+ ts int64
+ want string
+ }{
+ {"zero returns never", 0, "never"},
+ {"negative returns never", -1, "never"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatUnixSeconds(tt.ts)
+ if got != tt.want {
+ t.Errorf("formatUnixSeconds(%d) = %q, want %q", tt.ts, got, tt.want)
+ }
+ })
+ }
+
+ // Test positive value - just verify it returns a valid RFC3339 time string
+ t.Run("positive value returns RFC3339", func(t *testing.T) {
+ got := formatUnixSeconds(1704067200)
+ // Should contain date components (time.RFC3339 format)
+ if !strings.Contains(got, "2024") && !strings.Contains(got, "2023") {
+ t.Errorf("formatUnixSeconds(1704067200) should contain year, got %q", got)
+ }
+ if !strings.Contains(got, "T") {
+ t.Errorf("formatUnixSeconds(1704067200) should be RFC3339 format with T separator, got %q", got)
+ }
+ })
+}
+
+// ====================
+// users_create.go
+// ====================
+
+func TestUsersCreateCmd(t *testing.T) {
+ t.Run("create with password", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") {
+ var req admin.User
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "user-123",
+ "primaryEmail": req.PrimaryEmail,
+ "name": req.Name,
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersCreateCmd{
+ Email: "new@example.com",
+ FirstName: "New",
+ LastName: "User",
+ Password: "SecurePass123!",
+ OrgUnit: "/Sales",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created user:") || !strings.Contains(out, "new@example.com") {
+ t.Errorf("unexpected output: %s", out)
+ }
+ })
+
+ t.Run("create with generated password", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "user-456",
+ "primaryEmail": "gen@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersCreateCmd{
+ Email: "gen@example.com",
+ FirstName: "Generated",
+ LastName: "Password",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Generated password:") {
+ t.Errorf("expected generated password in output: %s", out)
+ }
+ })
+
+ t.Run("create with hash function", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") {
+ var req admin.User
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if req.HashFunction != "MD5" {
+ http.Error(w, "expected MD5 hash function", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "user-789",
+ "primaryEmail": "hash@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersCreateCmd{
+ Email: "hash@example.com",
+ FirstName: "Hash",
+ LastName: "User",
+ Password: "prehashed",
+ HashFunction: "md5",
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("create JSON output", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "user-json",
+ "primaryEmail": "json@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersCreateCmd{
+ Email: "json@example.com",
+ FirstName: "JSON",
+ LastName: "User",
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "generatedPassword") {
+ t.Errorf("expected generatedPassword in JSON: %s", out)
+ }
+ })
+
+ t.Run("invalid hash function", func(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersCreateCmd{
+ Email: "bad@example.com",
+ FirstName: "Bad",
+ LastName: "Hash",
+ Password: "pass",
+ HashFunction: "bcrypt",
+ }
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Error("expected error for invalid hash function")
+ }
+ })
+}
+
+// ====================
+// users_delete.go
+// ====================
+
+func TestUsersDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/users/user@example.com") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &UsersDeleteCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted user:") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+// ====================
+// users_password.go
+// ====================
+
+func TestUsersPasswordCmd(t *testing.T) {
+ t.Run("reset with specified password", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersPasswordCmd{
+ User: "user@example.com",
+ Password: "NewPass123!",
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Password reset for:") {
+ t.Errorf("expected success message: %s", out)
+ }
+ if strings.Contains(out, "New password:") {
+ t.Errorf("should not show password when specified: %s", out)
+ }
+ })
+
+ t.Run("reset with generated password", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersPasswordCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "New password:") {
+ t.Errorf("expected generated password in output: %s", out)
+ }
+ })
+
+ t.Run("reset JSON output", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersPasswordCmd{User: "user@example.com"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "generatedPassword") {
+ t.Errorf("expected generatedPassword in JSON: %s", out)
+ }
+ })
+
+ t.Run("reset with hash function", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ var req admin.User
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if req.HashFunction != "SHA-1" {
+ http.Error(w, "expected SHA-1 hash function", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersPasswordCmd{
+ User: "user@example.com",
+ Password: "prehashed",
+ HashFunction: "sha-1",
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+}
+
+// ====================
+// users_signout.go
+// ====================
+
+func TestUsersSignoutCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/signOut") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersSignoutCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Signed out user from all sessions:") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+// ====================
+// users_suspend.go
+// ====================
+
+func TestUsersSuspendCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/user@example.com") {
+ var req admin.User
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ // Verify Suspended is set to true - we check ForceSendFields was used
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ "suspended": true,
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersSuspendCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Suspended user:") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+func TestUsersUnsuspendCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/user@example.com") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ "suspended": false,
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersUnsuspendCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Unsuspended user:") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+// ====================
+// users_tokens.go
+// ====================
+
+func TestUsersTokensListCmd(t *testing.T) {
+ t.Run("list tokens plain", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tokens") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"clientId": "client1.apps.googleusercontent.com", "displayText": "My App", "scopes": []string{"email", "profile"}, "anonymous": false},
+ {"clientId": "client2.apps.googleusercontent.com", "displayText": "Other App", "scopes": []string{"drive"}, "anonymous": true},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersTokensListCmd{User: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "My App") || !strings.Contains(out, "Other App") {
+ t.Errorf("expected token names in output: %s", out)
+ }
+ })
+
+ t.Run("list tokens JSON", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tokens") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"clientId": "client1.apps.googleusercontent.com", "displayText": "My App"},
+ },
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersTokensListCmd{User: "user@example.com"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "items") {
+ t.Errorf("expected JSON output: %s", out)
+ }
+ })
+
+ t.Run("empty tokens", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tokens") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{},
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersTokensListCmd{User: "user@example.com"}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+}
+
+func TestUsersTokensDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/tokens/client1.apps.googleusercontent.com") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersTokensDeleteCmd{User: "user@example.com", ClientID: "client1.apps.googleusercontent.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Revoked token") {
+ t.Errorf("expected success message: %s", out)
+ }
+}
+
+// ====================
+// users_update.go (additional coverage)
+// ====================
+
+func TestUsersUpdateCmd_FieldUpdates(t *testing.T) {
+ t.Run("update multiple fields", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ firstName := "Updated"
+ lastName := "User"
+ orgUnit := "/Engineering"
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ FirstName: &firstName,
+ LastName: &lastName,
+ OrgUnit: &orgUnit,
+ }
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Updated user:") {
+ t.Errorf("expected success message: %s", out)
+ }
+ })
+
+ t.Run("update suspended state", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ "suspended": true,
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ suspended := true
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ Suspended: &suspended,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("update archived state", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ "archived": true,
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ archived := true
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ Archived: &archived,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("update recovery info", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ recoveryEmail := "recovery@example.com"
+ recoveryPhone := "+15555555555"
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ RecoveryEmail: &recoveryEmail,
+ RecoveryPhone: &recoveryPhone,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("clear recovery info", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ empty := ""
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ RecoveryEmail: &empty,
+ RecoveryPhone: &empty,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("update change password flag", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ changePassword := true
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ ChangePassword: &changePassword,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("update primary email", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "newemail@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newEmail := "newemail@example.com"
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ PrimaryEmail: &newEmail,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("no updates specified", func(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersUpdateCmd{User: "user@example.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Error("expected error for no updates")
+ }
+ })
+
+ t.Run("admin and field updates", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/makeAdmin") {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ admin := true
+ firstName := "Admin"
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ Admin: &admin,
+ FirstName: &firstName,
+ }
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ t.Run("JSON output", func(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "user@example.com",
+ "name": map[string]any{"givenName": "Test"},
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ firstName := "Test"
+ cmd := &UsersUpdateCmd{
+ User: "user@example.com",
+ FirstName: &firstName,
+ }
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "primaryEmail") {
+ t.Errorf("expected JSON output: %s", out)
+ }
+ })
+}
+
+// ====================
+// Validation tests (missing account)
+// ====================
+
+func TestUsers_MissingAccount(t *testing.T) {
+ tests := []struct {
+ name string
+ cmd interface {
+ Run(context.Context, *RootFlags) error
+ }
+ }{
+ {"TurnOff2SV", &UsersTurnOff2SVCmd{User: "user@example.com"}},
+ {"BackupCodesList", &UsersBackupCodesListCmd{User: "user@example.com"}},
+ {"BackupCodesGenerate", &UsersBackupCodesGenerateCmd{User: "user@example.com"}},
+ {"BackupCodesDelete", &UsersBackupCodesDeleteCmd{User: "user@example.com"}},
+ {"ASPsList", &UsersASPsListCmd{User: "user@example.com"}},
+ {"ASPsDelete", &UsersASPsDeleteCmd{User: "user@example.com", CodeID: 123}},
+ {"Create", &UsersCreateCmd{Email: "new@example.com", FirstName: "New", LastName: "User"}},
+ {"Delete", &UsersDeleteCmd{User: "user@example.com"}},
+ {"Password", &UsersPasswordCmd{User: "user@example.com"}},
+ {"Signout", &UsersSignoutCmd{User: "user@example.com"}},
+ {"Suspend", &UsersSuspendCmd{User: "user@example.com"}},
+ {"Unsuspend", &UsersUnsuspendCmd{User: "user@example.com"}},
+ {"TokensList", &UsersTokensListCmd{User: "user@example.com"}},
+ {"TokensDelete", &UsersTokensDeleteCmd{User: "user@example.com", ClientID: "client"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ flags := &RootFlags{Account: ""}
+ err := tt.cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Error("expected error for missing account")
+ }
+ })
+ }
+}
+
+// ====================
+// API error handling
+// ====================
+
+func TestUsers_APIErrors(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, `{"error": {"message": "Not Found"}}`, http.StatusNotFound)
+ })
+ stubAdminDirectory(t, h)
+
+ t.Run("delete API error", func(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &UsersDeleteCmd{User: "nonexistent@example.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Error("expected error for API failure")
+ }
+ })
+
+ t.Run("suspend API error", func(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersSuspendCmd{User: "nonexistent@example.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Error("expected error for API failure")
+ }
+ })
+
+ t.Run("signout API error", func(t *testing.T) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersSignoutCmd{User: "nonexistent@example.com"}
+
+ err := cmd.Run(testContext(t), flags)
+ if err == nil {
+ t.Error("expected error for API failure")
+ }
+ })
+}
diff --git a/internal/cmd/users_asps.go b/internal/cmd/users_asps.go
new file mode 100644
index 00000000..79c99839
--- /dev/null
+++ b/internal/cmd/users_asps.go
@@ -0,0 +1,94 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersASPsCmd struct {
+ List UsersASPsListCmd `cmd:"" name:"list" aliases:"ls" help:"List app-specific passwords"`
+ Delete UsersASPsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete an app-specific password"`
+}
+
+type UsersASPsListCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+}
+
+func (c *UsersASPsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ asps, err := svc.Asps.List(c.User).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list ASPs for %s: %w", c.User, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, asps)
+ }
+
+ if len(asps.Items) == 0 {
+ u.Err().Println("No app-specific passwords found")
+ return nil
+ }
+
+ tw, flush := tableWriter(ctx)
+ defer flush()
+
+ fmt.Fprintln(tw, "CODE ID\tNAME\tCREATED\tLAST USED")
+ for _, asp := range asps.Items {
+ fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n",
+ asp.CodeId,
+ sanitizeTab(asp.Name),
+ formatUnixSeconds(asp.CreationTime),
+ formatUnixSeconds(asp.LastTimeUsed),
+ )
+ }
+
+ return nil
+}
+
+type UsersASPsDeleteCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ CodeID int64 `arg:"" name:"code-id" help:"ASP code ID to delete"`
+}
+
+func (c *UsersASPsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Asps.Delete(c.User, c.CodeID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete ASP %d for %s: %w", c.CodeID, c.User, err)
+ }
+
+ u.Out().Printf("Deleted app-specific password %d for: %s\n", c.CodeID, c.User)
+ return nil
+}
+
+func formatUnixSeconds(ts int64) string {
+ if ts <= 0 {
+ return "never"
+ }
+ return time.Unix(ts, 0).Format(time.RFC3339)
+}
diff --git a/internal/cmd/users_count.go b/internal/cmd/users_count.go
new file mode 100644
index 00000000..c4944520
--- /dev/null
+++ b/internal/cmd/users_count.go
@@ -0,0 +1,95 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersCountCmd struct {
+ Domain string `name:"domain" short:"d" help:"Domain to count users from"`
+ OrgUnit string `name:"org-unit" aliases:"ou" help:"Organizational unit path to filter"`
+}
+
+func (c *UsersCountCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ domain := strings.TrimSpace(c.Domain)
+ if domain == "" {
+ domain = extractDomain(account)
+ }
+
+ call := svc.Users.List().Domain(domain).MaxResults(500).Projection("basic").Fields("users(orgUnitPath),nextPageToken")
+ if c.OrgUnit != "" {
+ call = call.Query(fmt.Sprintf("orgUnitPath='%s'", c.OrgUnit))
+ }
+
+ counts := make(map[string]int)
+ for {
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list users: %w", err)
+ }
+ for _, user := range resp.Users {
+ if user == nil {
+ continue
+ }
+ path := user.OrgUnitPath
+ if path == "" {
+ path = "/"
+ }
+ counts[path]++
+ }
+ if resp.NextPageToken == "" {
+ break
+ }
+ call = call.PageToken(resp.NextPageToken)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ type item struct {
+ OrgUnitPath string `json:"orgUnitPath"`
+ Count int `json:"count"`
+ }
+ items := make([]item, 0, len(counts))
+ for path, count := range counts {
+ items = append(items, item{OrgUnitPath: path, Count: count})
+ }
+ sort.Slice(items, func(i, j int) bool { return items[i].OrgUnitPath < items[j].OrgUnitPath })
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"counts": items})
+ }
+
+ if len(counts) == 0 {
+ u.Err().Println("No users found")
+ return nil
+ }
+
+ paths := make([]string, 0, len(counts))
+ for path := range counts {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+
+ tw, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(tw, "ORG UNIT\tCOUNT")
+ for _, path := range paths {
+ fmt.Fprintf(tw, "%s\t%d\n", sanitizeTab(path), counts[path])
+ }
+
+ return nil
+}
diff --git a/internal/cmd/users_create.go b/internal/cmd/users_create.go
new file mode 100644
index 00000000..32c3401d
--- /dev/null
+++ b/internal/cmd/users_create.go
@@ -0,0 +1,103 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersCreateCmd struct {
+ Email string `arg:"" name:"email" help:"Primary email address"`
+ FirstName string `name:"first-name" aliases:"given-name,fn" required:"" help:"First/given name"`
+ LastName string `name:"last-name" aliases:"family-name,ln" required:"" help:"Last/family name"`
+ Password string `name:"password" aliases:"pass" help:"Password (generated if not specified)"`
+ OrgUnit string `name:"org-unit" aliases:"ou" default:"/" help:"Organizational unit path"`
+ ChangePassword bool `name:"change-password" default:"true" help:"Require password change on first login"`
+ Suspended bool `name:"suspended" help:"Create user in suspended state"`
+ Archived bool `name:"archived" help:"Create user in archived state"`
+ RecoveryEmail string `name:"recovery-email" help:"Recovery email address"`
+ RecoveryPhone string `name:"recovery-phone" help:"Recovery phone number (E.164 format)"`
+ HashFunction string `name:"hash-function" help:"Password hash function if pre-hashed (MD5, SHA-1, crypt)"`
+}
+
+func (c *UsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ password := c.Password
+ generated := false
+ if password == "" {
+ password, err = generatePassword(16)
+ if err != nil {
+ return fmt.Errorf("generate password: %w", err)
+ }
+ generated = true
+ }
+
+ user := &admin.User{
+ PrimaryEmail: c.Email,
+ Name: &admin.UserName{
+ GivenName: c.FirstName,
+ FamilyName: c.LastName,
+ },
+ Password: password,
+ ChangePasswordAtNextLogin: c.ChangePassword,
+ OrgUnitPath: c.OrgUnit,
+ Suspended: c.Suspended,
+ Archived: c.Archived,
+ }
+
+ if c.HashFunction != "" {
+ var hash string
+ hash, err = normalizeUserHashFunction(c.HashFunction)
+ if err != nil {
+ return err
+ }
+ user.HashFunction = hash
+ }
+ if c.RecoveryEmail != "" {
+ user.RecoveryEmail = c.RecoveryEmail
+ }
+ if c.RecoveryPhone != "" {
+ user.RecoveryPhone = c.RecoveryPhone
+ }
+
+ created, err := svc.Users.Insert(user).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create user: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ result := map[string]any{
+ "user": created,
+ }
+ if generated {
+ result["generatedPassword"] = password
+ }
+ return outfmt.WriteJSON(os.Stdout, result)
+ }
+
+ u.Out().Printf("Created user: %s\n", created.PrimaryEmail)
+ u.Out().Printf("User ID: %s\n", created.Id)
+ if generated {
+ u.Out().Printf("Generated password: %s\n", password)
+ }
+ if c.ChangePassword {
+ u.Out().Println("User must change password on first login")
+ }
+
+ return nil
+}
diff --git a/internal/cmd/users_delete.go b/internal/cmd/users_delete.go
new file mode 100644
index 00000000..45d749f9
--- /dev/null
+++ b/internal/cmd/users_delete.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersDeleteCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID to delete"`
+}
+
+func (c *UsersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete user %s", c.User)); err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Users.Delete(c.User).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete user %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Deleted user: %s\n", c.User)
+ return nil
+}
diff --git a/internal/cmd/users_get.go b/internal/cmd/users_get.go
new file mode 100644
index 00000000..510c2478
--- /dev/null
+++ b/internal/cmd/users_get.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersGetCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ Projection string `name:"projection" default:"full" enum:"basic,full,custom" help:"Data projection"`
+}
+
+func (c *UsersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ user, err := svc.Users.Get(c.User).Projection(c.Projection).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get user %s: %w", c.User, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, user)
+ }
+
+ name := ""
+ if user.Name != nil {
+ name = strings.TrimSpace(strings.Join([]string{user.Name.GivenName, user.Name.FamilyName}, " "))
+ }
+
+ u.Out().Printf("Email: %s\n", user.PrimaryEmail)
+ u.Out().Printf("Name: %s\n", name)
+ u.Out().Printf("ID: %s\n", user.Id)
+ u.Out().Printf("Is Admin: %v\n", user.IsAdmin)
+ u.Out().Printf("Is Delegated Admin: %v\n", user.IsDelegatedAdmin)
+ u.Out().Printf("Suspended: %v\n", user.Suspended)
+ if user.SuspensionReason != "" {
+ u.Out().Printf("Suspension Reason: %s\n", user.SuspensionReason)
+ }
+ u.Out().Printf("Archived: %v\n", user.Archived)
+ u.Out().Printf("Org Unit: %s\n", user.OrgUnitPath)
+ u.Out().Printf("Creation Time: %s\n", user.CreationTime)
+ u.Out().Printf("Last Login: %s\n", user.LastLoginTime)
+ u.Out().Printf("Agreed to Terms: %v\n", user.AgreedToTerms)
+ u.Out().Printf("Change Password: %v\n", user.ChangePasswordAtNextLogin)
+ u.Out().Printf("2SV Enrolled: %v\n", user.IsEnrolledIn2Sv)
+ u.Out().Printf("2SV Enforced: %v\n", user.IsEnforcedIn2Sv)
+
+ if user.RecoveryEmail != "" {
+ u.Out().Printf("Recovery Email: %s\n", user.RecoveryEmail)
+ }
+ if user.RecoveryPhone != "" {
+ u.Out().Printf("Recovery Phone: %s\n", user.RecoveryPhone)
+ }
+
+ if len(user.Aliases) > 0 {
+ u.Out().Printf("Aliases: %v\n", user.Aliases)
+ }
+ if len(user.NonEditableAliases) > 0 {
+ u.Out().Printf("Non-Editable: %v\n", user.NonEditableAliases)
+ }
+
+ return nil
+}
diff --git a/internal/cmd/users_list.go b/internal/cmd/users_list.go
new file mode 100644
index 00000000..e745613f
--- /dev/null
+++ b/internal/cmd/users_list.go
@@ -0,0 +1,141 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersListCmd struct {
+ Domain string `name:"domain" short:"d" help:"Domain to list users from"`
+ Query string `name:"query" short:"q" help:"Search query (e.g., 'email:admin*', 'name:John*', 'orgUnitPath=/Sales')"`
+ OrgUnit string `name:"org-unit" aliases:"ou" help:"Organizational unit path"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Maximum users to return"`
+ Page string `name:"page" help:"Page token for pagination"`
+ Suspended *bool `name:"suspended" help:"Filter by suspended state"`
+ Admin *bool `name:"admin" help:"Filter by admin status"`
+ OrderBy string `name:"order-by" default:"email" enum:"email,familyName,givenName" help:"Sort field"`
+ SortOrder string `name:"sort-order" default:"ASCENDING" enum:"ASCENDING,DESCENDING" help:"Sort direction"`
+ Projection string `name:"projection" default:"basic" enum:"basic,full,custom" help:"Amount of user data to return"`
+ Fields string `name:"fields" help:"Custom fields to return (comma-separated)"`
+ ToDrive ToDriveFlags `embed:""`
+}
+
+func (c *UsersListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Users.List()
+
+ domain := strings.TrimSpace(c.Domain)
+ if domain == "" {
+ domain = extractDomain(account)
+ }
+ call = call.Domain(domain)
+
+ var queryParts []string
+ if c.Query != "" {
+ queryParts = append(queryParts, c.Query)
+ }
+ if c.OrgUnit != "" {
+ queryParts = append(queryParts, fmt.Sprintf("orgUnitPath='%s'", c.OrgUnit))
+ }
+ if c.Suspended != nil {
+ queryParts = append(queryParts, fmt.Sprintf("isSuspended=%v", *c.Suspended))
+ }
+ if c.Admin != nil {
+ queryParts = append(queryParts, fmt.Sprintf("isAdmin=%v", *c.Admin))
+ }
+ if len(queryParts) > 0 {
+ call = call.Query(strings.Join(queryParts, " "))
+ }
+
+ call = call.MaxResults(c.Max)
+ call = call.OrderBy(c.OrderBy)
+ call = call.SortOrder(c.SortOrder)
+ call = call.Projection(c.Projection)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+ if c.Fields != "" {
+ call = call.CustomFieldMask(c.Fields)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list users: %w", err)
+ }
+
+ if len(resp.Users) == 0 {
+ u.Err().Println("No users found")
+ return nil
+ }
+
+ rows := make([][]string, 0, len(resp.Users))
+ for _, user := range resp.Users {
+ if user == nil {
+ continue
+ }
+ suspended := ""
+ if user.Suspended {
+ suspended = "yes"
+ }
+ admin := ""
+ if user.IsAdmin {
+ admin = "yes"
+ }
+ name := ""
+ if user.Name != nil {
+ name = strings.TrimSpace(strings.Join([]string{user.Name.GivenName, user.Name.FamilyName}, " "))
+ }
+ rows = append(rows, toDriveRow(
+ user.PrimaryEmail,
+ name,
+ suspended,
+ admin,
+ user.OrgUnitPath,
+ formatDateTime(user.LastLoginTime),
+ ))
+ }
+
+ if ok, err := writeToDrive(ctx, flags, toDriveTitle("Users", c.ToDrive), []string{"EMAIL", "NAME", "SUSPENDED", "ADMIN", "ORG UNIT", "LAST LOGIN"}, rows, c.ToDrive); ok {
+ return err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ tw, flush := tableWriter(ctx)
+ defer flush()
+
+ fmt.Fprintln(tw, "EMAIL\tNAME\tSUSPENDED\tADMIN\tORG UNIT\tLAST LOGIN")
+ for _, row := range rows {
+ if len(row) < 6 {
+ continue
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
+ sanitizeTab(row[0]),
+ sanitizeTab(row[1]),
+ sanitizeTab(row[2]),
+ sanitizeTab(row[3]),
+ sanitizeTab(row[4]),
+ sanitizeTab(row[5]),
+ )
+ }
+
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
diff --git a/internal/cmd/users_password.go b/internal/cmd/users_password.go
new file mode 100644
index 00000000..3fecc0bd
--- /dev/null
+++ b/internal/cmd/users_password.go
@@ -0,0 +1,81 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersPasswordCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ Password string `name:"password" aliases:"pass" help:"New password (generated if not specified)"`
+ ChangePassword bool `name:"change-password" default:"true" help:"Require password change on next login"`
+ HashFunction string `name:"hash-function" help:"Password hash function if pre-hashed (MD5, SHA-1, crypt)"`
+}
+
+func (c *UsersPasswordCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ password := c.Password
+ generated := false
+ if password == "" {
+ password, err = generatePassword(16)
+ if err != nil {
+ return fmt.Errorf("generate password: %w", err)
+ }
+ generated = true
+ }
+
+ user := &admin.User{
+ Password: password,
+ ChangePasswordAtNextLogin: c.ChangePassword,
+ }
+ user.ForceSendFields = append(user.ForceSendFields, "ChangePasswordAtNextLogin")
+ if c.HashFunction != "" {
+ var hash string
+ hash, err = normalizeUserHashFunction(c.HashFunction)
+ if err != nil {
+ return err
+ }
+ user.HashFunction = hash
+ }
+
+ updated, err := svc.Users.Update(c.User, user).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("reset password for %s: %w", c.User, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ result := map[string]any{
+ "user": updated.PrimaryEmail,
+ }
+ if generated {
+ result["generatedPassword"] = password
+ }
+ return outfmt.WriteJSON(os.Stdout, result)
+ }
+
+ u.Out().Printf("Password reset for: %s\n", updated.PrimaryEmail)
+ if generated {
+ u.Out().Printf("New password: %s\n", password)
+ }
+ if c.ChangePassword {
+ u.Out().Println("User must change password on next login")
+ }
+
+ return nil
+}
diff --git a/internal/cmd/users_signout.go b/internal/cmd/users_signout.go
new file mode 100644
index 00000000..67568817
--- /dev/null
+++ b/internal/cmd/users_signout.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersSignoutCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID to sign out"`
+}
+
+func (c *UsersSignoutCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Users.SignOut(c.User).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("sign out user %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Signed out user from all sessions: %s\n", c.User)
+ return nil
+}
diff --git a/internal/cmd/users_suspend.go b/internal/cmd/users_suspend.go
new file mode 100644
index 00000000..eb41202b
--- /dev/null
+++ b/internal/cmd/users_suspend.go
@@ -0,0 +1,64 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersSuspendCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID to suspend"`
+}
+
+func (c *UsersSuspendCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ user := &admin.User{Suspended: true}
+ user.ForceSendFields = append(user.ForceSendFields, "Suspended")
+
+ if _, err := svc.Users.Update(c.User, user).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("suspend user %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Suspended user: %s\n", c.User)
+ return nil
+}
+
+type UsersUnsuspendCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID to unsuspend"`
+}
+
+func (c *UsersUnsuspendCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ user := &admin.User{Suspended: false}
+ user.ForceSendFields = append(user.ForceSendFields, "Suspended")
+
+ if _, err := svc.Users.Update(c.User, user).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("unsuspend user %s: %w", c.User, err)
+ }
+
+ u.Out().Printf("Unsuspended user: %s\n", c.User)
+ return nil
+}
diff --git a/internal/cmd/users_test.go b/internal/cmd/users_test.go
new file mode 100644
index 00000000..f52e34bb
--- /dev/null
+++ b/internal/cmd/users_test.go
@@ -0,0 +1,192 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ admin "google.golang.org/api/admin/directory/v1"
+ "google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+func TestUsersListCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/users") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "users": []map[string]any{
+ {
+ "primaryEmail": "alex@example.com",
+ "name": map[string]any{"givenName": "Alex", "familyName": "Admin"},
+ "orgUnitPath": "/",
+ "suspended": false,
+ "isAdmin": true,
+ "lastLoginTime": "2026-01-01T00:00:00Z",
+ },
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersListCmd{}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(ctx, flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "users") {
+ t.Fatalf("expected JSON users output, got: %s", out)
+ }
+}
+
+func TestUsersGetCmd_Plain(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/users/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "primaryEmail": "sam@example.com",
+ "name": map[string]any{"givenName": "Sam", "familyName": "User"},
+ "id": "user-1",
+ "isAdmin": false,
+ "suspended": false,
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersGetCmd{User: "sam@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Email:") || !strings.Contains(out, "sam@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestUsersUpdateCmd_AdminOnly(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/makeAdmin") {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"status": true})
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersUpdateCmd{User: "sam@example.com", Admin: boolPtr(true)}
+
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestUsersCountCmd(t *testing.T) {
+ calls := 0
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/users") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ calls++
+ if calls == 1 {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "users": []map[string]any{
+ {"orgUnitPath": "/"},
+ {"orgUnitPath": "/Sales"},
+ },
+ "nextPageToken": "next",
+ })
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "users": []map[string]any{
+ {"orgUnitPath": "/Sales"},
+ },
+ })
+ })
+ stubAdminDirectory(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &UsersCountCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "/Sales") || !strings.Contains(out, "2") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func stubAdminDirectory(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newAdminDirectory
+ svc, err := newAdminDirectoryForServer(srv)
+ if err != nil {
+ t.Fatalf("new admin service: %v", err)
+ }
+ newAdminDirectory = func(context.Context, string) (*admin.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newAdminDirectory = orig
+ srv.Close()
+ })
+}
+
+func newAdminDirectoryForServer(srv *httptest.Server) (*admin.Service, error) {
+ return admin.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+}
+
+func testContext(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func testContextWithStdout(t *testing.T) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func boolPtr(v bool) *bool { return &v }
diff --git a/internal/cmd/users_tokens.go b/internal/cmd/users_tokens.go
new file mode 100644
index 00000000..834f420a
--- /dev/null
+++ b/internal/cmd/users_tokens.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersTokensCmd struct {
+ List UsersTokensListCmd `cmd:"" name:"list" aliases:"ls" help:"List user tokens"`
+ Delete UsersTokensDeleteCmd `cmd:"" name:"delete" aliases:"rm,revoke" help:"Revoke a token"`
+}
+
+type UsersTokensListCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+}
+
+func (c *UsersTokensListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ tokens, err := svc.Tokens.List(c.User).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list tokens for %s: %w", c.User, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, tokens)
+ }
+
+ if len(tokens.Items) == 0 {
+ u.Err().Println("No tokens found")
+ return nil
+ }
+
+ tw, flush := tableWriter(ctx)
+ defer flush()
+
+ fmt.Fprintln(tw, "CLIENT ID\tDISPLAY TEXT\tSCOPES\tANONYMOUS")
+ for _, token := range tokens.Items {
+ fmt.Fprintf(tw, "%s\t%s\t%d\t%v\n",
+ sanitizeTab(token.ClientId),
+ sanitizeTab(token.DisplayText),
+ len(token.Scopes),
+ token.Anonymous,
+ )
+ }
+
+ return nil
+}
+
+type UsersTokensDeleteCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ ClientID string `arg:"" name:"client-id" help:"OAuth client ID to revoke"`
+}
+
+func (c *UsersTokensDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.Tokens.Delete(c.User, c.ClientID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete token %s for %s: %w", c.ClientID, c.User, err)
+ }
+
+ u.Out().Printf("Revoked token %s for: %s\n", c.ClientID, c.User)
+ return nil
+}
diff --git a/internal/cmd/users_update.go b/internal/cmd/users_update.go
new file mode 100644
index 00000000..031ae147
--- /dev/null
+++ b/internal/cmd/users_update.go
@@ -0,0 +1,117 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type UsersUpdateCmd struct {
+ User string `arg:"" name:"user" help:"User email or ID"`
+ FirstName *string `name:"first-name" aliases:"given-name,fn" help:"First/given name"`
+ LastName *string `name:"last-name" aliases:"family-name,ln" help:"Last/family name"`
+ PrimaryEmail *string `name:"primary-email" help:"Change primary email address"`
+ OrgUnit *string `name:"org-unit" aliases:"ou" help:"Organizational unit path"`
+ Suspended *bool `name:"suspended" help:"Suspended state"`
+ Archived *bool `name:"archived" help:"Archived state"`
+ RecoveryEmail *string `name:"recovery-email" help:"Recovery email"`
+ RecoveryPhone *string `name:"recovery-phone" help:"Recovery phone"`
+ ChangePassword *bool `name:"change-password" help:"Require password change on next login"`
+ Admin *bool `name:"admin" help:"Super admin status (use with caution)"`
+}
+
+func (c *UsersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ user := &admin.User{}
+ hasFieldUpdates := false
+
+ if c.FirstName != nil || c.LastName != nil {
+ user.Name = &admin.UserName{}
+ if c.FirstName != nil {
+ user.Name.GivenName = *c.FirstName
+ hasFieldUpdates = true
+ }
+ if c.LastName != nil {
+ user.Name.FamilyName = *c.LastName
+ hasFieldUpdates = true
+ }
+ }
+ if c.PrimaryEmail != nil {
+ user.PrimaryEmail = *c.PrimaryEmail
+ hasFieldUpdates = true
+ }
+ if c.OrgUnit != nil {
+ user.OrgUnitPath = *c.OrgUnit
+ hasFieldUpdates = true
+ }
+ if c.Suspended != nil {
+ user.Suspended = *c.Suspended
+ user.ForceSendFields = append(user.ForceSendFields, "Suspended")
+ hasFieldUpdates = true
+ }
+ if c.Archived != nil {
+ user.Archived = *c.Archived
+ user.ForceSendFields = append(user.ForceSendFields, "Archived")
+ hasFieldUpdates = true
+ }
+ if c.RecoveryEmail != nil {
+ user.RecoveryEmail = *c.RecoveryEmail
+ if *c.RecoveryEmail == "" {
+ user.ForceSendFields = append(user.ForceSendFields, "RecoveryEmail")
+ }
+ hasFieldUpdates = true
+ }
+ if c.RecoveryPhone != nil {
+ user.RecoveryPhone = *c.RecoveryPhone
+ if *c.RecoveryPhone == "" {
+ user.ForceSendFields = append(user.ForceSendFields, "RecoveryPhone")
+ }
+ hasFieldUpdates = true
+ }
+ if c.ChangePassword != nil {
+ user.ChangePasswordAtNextLogin = *c.ChangePassword
+ user.ForceSendFields = append(user.ForceSendFields, "ChangePasswordAtNextLogin")
+ hasFieldUpdates = true
+ }
+
+ if c.Admin == nil && !hasFieldUpdates {
+ return usage("no updates specified")
+ }
+
+ if c.Admin != nil {
+ if err = svc.Users.MakeAdmin(c.User, &admin.UserMakeAdmin{Status: *c.Admin}).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("update admin status for %s: %w", c.User, err)
+ }
+ if !hasFieldUpdates {
+ u.Out().Printf("Updated admin status for: %s\n", c.User)
+ return nil
+ }
+ }
+
+ updated, err := svc.Users.Update(c.User, user).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update user %s: %w", c.User, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u.Out().Printf("Updated user: %s\n", updated.PrimaryEmail)
+ return nil
+}
diff --git a/internal/cmd/vault.go b/internal/cmd/vault.go
new file mode 100644
index 00000000..77350a15
--- /dev/null
+++ b/internal/cmd/vault.go
@@ -0,0 +1,14 @@
+package cmd
+
+import "github.com/steipete/gogcli/internal/googleapi"
+
+var (
+ newVaultService = googleapi.NewVault
+ newStorageService = googleapi.NewStorage
+)
+
+type VaultCmd struct {
+ Matters VaultMattersCmd `cmd:"" name:"matters" help:"Manage Vault matters"`
+ Holds VaultHoldsCmd `cmd:"" name:"holds" help:"Manage Vault holds"`
+ Exports VaultExportsCmd `cmd:"" name:"exports" help:"Manage Vault exports"`
+}
diff --git a/internal/cmd/vault_exports.go b/internal/cmd/vault_exports.go
new file mode 100644
index 00000000..5010e2ae
--- /dev/null
+++ b/internal/cmd/vault_exports.go
@@ -0,0 +1,250 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "google.golang.org/api/storage/v1"
+ "google.golang.org/api/vault/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type VaultExportsCmd struct {
+ List VaultExportsListCmd `cmd:"" name:"list" aliases:"ls" help:"List exports"`
+ Get VaultExportsGetCmd `cmd:"" name:"get" help:"Get export"`
+ Create VaultExportsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create export"`
+ Download VaultExportsDownloadCmd `cmd:"" name:"download" help:"Download export files"`
+}
+
+type VaultExportsListCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *VaultExportsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Matters.Exports.List(c.MatterID).PageSize(c.Max)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list exports: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Exports) == 0 {
+ u.Err().Println("No exports found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "EXPORT ID\tNAME\tSTATUS\tCREATED")
+ for _, exp := range resp.Exports {
+ if exp == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(exp.Id),
+ sanitizeTab(exp.Name),
+ sanitizeTab(exp.Status),
+ sanitizeTab(exp.CreateTime),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type VaultExportsGetCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ ExportID string `arg:"" name:"export-id" help:"Export ID"`
+}
+
+func (c *VaultExportsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ exp, err := svc.Matters.Exports.Get(c.MatterID, c.ExportID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get export %s: %w", c.ExportID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, exp)
+ }
+
+ u.Out().Printf("Export ID: %s\n", exp.Id)
+ u.Out().Printf("Name: %s\n", exp.Name)
+ u.Out().Printf("Status: %s\n", exp.Status)
+ u.Out().Printf("Created: %s\n", exp.CreateTime)
+ if exp.Query != nil {
+ u.Out().Printf("Corpus: %s\n", exp.Query.Corpus)
+ }
+ return nil
+}
+
+type VaultExportsCreateCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ Name string `name:"name" required:"" help:"Export name"`
+ Query string `name:"query" help:"Search query terms"`
+}
+
+func (c *VaultExportsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ query := &vault.Query{
+ Corpus: "MAIL",
+ SearchMethod: "ENTIRE_ORG",
+ DataScope: "ALL_DATA",
+ }
+ if strings.TrimSpace(c.Query) != "" {
+ query.Terms = c.Query
+ }
+
+ export := &vault.Export{
+ Name: c.Name,
+ Query: query,
+ }
+
+ created, err := svc.Matters.Exports.Create(c.MatterID, export).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create export: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created export: %s (%s)\n", created.Name, created.Id)
+ return nil
+}
+
+type VaultExportsDownloadCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ ExportID string `arg:"" name:"export-id" help:"Export ID"`
+ Output string `name:"output" required:"" help:"Output directory"`
+}
+
+func (c *VaultExportsDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ exp, err := svc.Matters.Exports.Get(c.MatterID, c.ExportID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get export %s: %w", c.ExportID, err)
+ }
+ if exp.CloudStorageSink == nil || len(exp.CloudStorageSink.Files) == 0 {
+ return fmt.Errorf("export %s has no Cloud Storage files", c.ExportID)
+ }
+
+ if err = os.MkdirAll(c.Output, 0o750); err != nil {
+ return fmt.Errorf("create output dir: %w", err)
+ }
+
+ storageSvc, err := newStorageService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ results := make([]map[string]string, 0, len(exp.CloudStorageSink.Files))
+ for _, file := range exp.CloudStorageSink.Files {
+ if file == nil {
+ continue
+ }
+ path, err := downloadExportFile(ctx, storageSvc, file, c.Output)
+ if err != nil {
+ return err
+ }
+ results = append(results, map[string]string{
+ "bucket": file.BucketName,
+ "object": file.ObjectName,
+ "path": path,
+ })
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, map[string]any{"files": results})
+ }
+
+ for _, item := range results {
+ u.Out().Printf("Downloaded %s/%s -> %s\n", item["bucket"], item["object"], item["path"])
+ }
+ return nil
+}
+
+func downloadExportFile(ctx context.Context, svc *storage.Service, file *vault.CloudStorageFile, outputDir string) (string, error) {
+ if file.BucketName == "" || file.ObjectName == "" {
+ return "", fmt.Errorf("invalid storage file metadata")
+ }
+
+ resp, err := svc.Objects.Get(file.BucketName, file.ObjectName).Context(ctx).Download()
+ if err != nil {
+ return "", fmt.Errorf("download %s/%s: %w", file.BucketName, file.ObjectName, err)
+ }
+ defer resp.Body.Close()
+
+ name := filepath.Base(file.ObjectName)
+ if name == "." || name == "/" || name == "" {
+ name = "export"
+ }
+ path := filepath.Join(outputDir, name)
+
+ out, err := os.Create(path) //nolint:gosec // path is constructed from validated components
+ if err != nil {
+ return "", fmt.Errorf("create file: %w", err)
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ return "", fmt.Errorf("write file: %w", err)
+ }
+
+ return path, nil
+}
diff --git a/internal/cmd/vault_holds.go b/internal/cmd/vault_holds.go
new file mode 100644
index 00000000..09c8d577
--- /dev/null
+++ b/internal/cmd/vault_holds.go
@@ -0,0 +1,233 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/vault/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type VaultHoldsCmd struct {
+ List VaultHoldsListCmd `cmd:"" name:"list" aliases:"ls" help:"List holds"`
+ Get VaultHoldsGetCmd `cmd:"" name:"get" help:"Get hold"`
+ Create VaultHoldsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create hold"`
+ Delete VaultHoldsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete hold"`
+}
+
+type VaultHoldsListCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *VaultHoldsListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Matters.Holds.List(c.MatterID).PageSize(c.Max)
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list holds: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Holds) == 0 {
+ u.Err().Println("No holds found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "HOLD ID\tNAME\tCORPUS\tSCOPE")
+ for _, hold := range resp.Holds {
+ if hold == nil {
+ continue
+ }
+ scope := holdScope(hold)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(hold.HoldId),
+ sanitizeTab(hold.Name),
+ sanitizeTab(hold.Corpus),
+ sanitizeTab(scope),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type VaultHoldsGetCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ HoldID string `arg:"" name:"hold-id" help:"Hold ID"`
+}
+
+func (c *VaultHoldsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ hold, err := svc.Matters.Holds.Get(c.MatterID, c.HoldID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get hold %s: %w", c.HoldID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, hold)
+ }
+
+ u.Out().Printf("Hold ID: %s\n", hold.HoldId)
+ u.Out().Printf("Name: %s\n", hold.Name)
+ u.Out().Printf("Corpus: %s\n", hold.Corpus)
+ u.Out().Printf("Scope: %s\n", holdScope(hold))
+ return nil
+}
+
+type VaultHoldsCreateCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ Name string `name:"name" required:"" help:"Hold name"`
+ Corpus string `name:"corpus" required:"" enum:"MAIL,DRIVE,GROUPS" help:"Corpus to hold"`
+ Accounts string `name:"accounts" help:"Comma-separated account emails"`
+ OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path"`
+ Query string `name:"query" help:"Search query"`
+}
+
+func (c *VaultHoldsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ hold := &vault.Hold{
+ Name: c.Name,
+ Corpus: strings.ToUpper(c.Corpus),
+ }
+
+ accounts := splitCSV(c.Accounts)
+ orgUnit := strings.TrimSpace(c.OrgUnit)
+ if len(accounts) == 0 && orgUnit == "" {
+ return usage("--accounts or --org-unit required")
+ }
+ if len(accounts) > 0 && orgUnit != "" {
+ return usage("use only one of --accounts or --org-unit")
+ }
+
+ if len(accounts) > 0 {
+ hold.Accounts = make([]*vault.HeldAccount, 0, len(accounts))
+ for _, email := range accounts {
+ email = strings.TrimSpace(email)
+ if email == "" {
+ continue
+ }
+ hold.Accounts = append(hold.Accounts, &vault.HeldAccount{Email: email})
+ }
+ }
+
+ if orgUnit != "" {
+ adminSvc, adminErr := newAdminDirectory(ctx, account)
+ if adminErr != nil {
+ return adminErr
+ }
+ orgID, orgErr := resolveOrgUnitID(ctx, adminSvc, orgUnit)
+ if orgErr != nil {
+ return orgErr
+ }
+ hold.OrgUnit = &vault.HeldOrgUnit{OrgUnitId: orgID}
+ }
+
+ if strings.TrimSpace(c.Query) != "" {
+ switch hold.Corpus {
+ case "DRIVE":
+ return usage("drive holds do not support --query")
+ case "MAIL":
+ hold.Query = &vault.CorpusQuery{MailQuery: &vault.HeldMailQuery{Terms: c.Query}}
+ case "GROUPS":
+ hold.Query = &vault.CorpusQuery{GroupsQuery: &vault.HeldGroupsQuery{Terms: c.Query}}
+ }
+ }
+
+ created, err := svc.Matters.Holds.Create(c.MatterID, hold).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create hold: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created hold: %s (%s)\n", created.Name, created.HoldId)
+ return nil
+}
+
+type VaultHoldsDeleteCmd struct {
+ MatterID string `name:"matter" required:"" help:"Matter ID"`
+ HoldID string `arg:"" name:"hold-id" help:"Hold ID"`
+}
+
+func (c *VaultHoldsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete hold %s", c.HoldID)); err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if _, err := svc.Matters.Holds.Delete(c.MatterID, c.HoldID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete hold %s: %w", c.HoldID, err)
+ }
+
+ u.Out().Printf("Deleted hold: %s\n", c.HoldID)
+ return nil
+}
+
+func holdScope(hold *vault.Hold) string {
+ if hold == nil {
+ return ""
+ }
+ if hold.OrgUnit != nil && hold.OrgUnit.OrgUnitId != "" {
+ return "org-unit"
+ }
+ if len(hold.Accounts) > 0 {
+ return fmt.Sprintf("accounts:%d", len(hold.Accounts))
+ }
+ return ""
+}
diff --git a/internal/cmd/vault_matters.go b/internal/cmd/vault_matters.go
new file mode 100644
index 00000000..ca34cd1c
--- /dev/null
+++ b/internal/cmd/vault_matters.go
@@ -0,0 +1,299 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "google.golang.org/api/vault/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type VaultMattersCmd struct {
+ List VaultMattersListCmd `cmd:"" name:"list" aliases:"ls" help:"List Vault matters"`
+ Get VaultMattersGetCmd `cmd:"" name:"get" help:"Get Vault matter"`
+ Create VaultMattersCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create Vault matter"`
+ Update VaultMattersUpdateCmd `cmd:"" name:"update" help:"Update Vault matter"`
+ Close VaultMattersCloseCmd `cmd:"" name:"close" help:"Close Vault matter"`
+ Reopen VaultMattersReopenCmd `cmd:"" name:"reopen" help:"Reopen Vault matter"`
+ Delete VaultMattersDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete Vault matter"`
+}
+
+type VaultMattersListCmd struct {
+ State string `name:"state" help:"Filter by state (OPEN, CLOSED, DELETED)"`
+ Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *VaultMattersListCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Matters.List().View("BASIC")
+ if state := strings.ToUpper(strings.TrimSpace(c.State)); state != "" {
+ switch state {
+ case "OPEN", "CLOSED", "DELETED":
+ call = call.State(state)
+ default:
+ return usage("invalid --state (expected OPEN, CLOSED, DELETED)")
+ }
+ }
+ if c.Max > 0 {
+ call = call.PageSize(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list matters: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Matters) == 0 {
+ u.Err().Println("No matters found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "MATTER ID\tNAME\tSTATE")
+ for _, matter := range resp.Matters {
+ if matter == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ sanitizeTab(matter.MatterId),
+ sanitizeTab(matter.Name),
+ sanitizeTab(matter.State),
+ )
+ }
+ printNextPageHint(u, resp.NextPageToken)
+ return nil
+}
+
+type VaultMattersGetCmd struct {
+ MatterID string `arg:"" name:"matter-id" help:"Matter ID"`
+}
+
+func (c *VaultMattersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ matter, err := svc.Matters.Get(c.MatterID).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get matter %s: %w", c.MatterID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, matter)
+ }
+
+ u.Out().Printf("Matter ID: %s\n", matter.MatterId)
+ u.Out().Printf("Name: %s\n", matter.Name)
+ u.Out().Printf("State: %s\n", matter.State)
+ if matter.Description != "" {
+ u.Out().Printf("Description: %s\n", matter.Description)
+ }
+ return nil
+}
+
+type VaultMattersCreateCmd struct {
+ Name string `arg:"" name:"name" help:"Matter name"`
+ Description string `name:"description" help:"Description"`
+}
+
+func (c *VaultMattersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ matter := &vault.Matter{Name: c.Name, Description: c.Description}
+ created, err := svc.Matters.Create(matter).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("create matter %s: %w", c.Name, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, created)
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created matter: %s (%s)\n", created.Name, created.MatterId)
+ return nil
+}
+
+type VaultMattersUpdateCmd struct {
+ MatterID string `arg:"" name:"matter-id" help:"Matter ID"`
+ Name *string `name:"name" help:"Matter name"`
+ Description *string `name:"description" help:"Description"`
+}
+
+func (c *VaultMattersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ matter := &vault.Matter{}
+ hasUpdates := false
+ if c.Name != nil {
+ matter.Name = *c.Name
+ hasUpdates = true
+ }
+ if c.Description != nil {
+ matter.Description = *c.Description
+ if *c.Description == "" {
+ matter.ForceSendFields = append(matter.ForceSendFields, "Description")
+ }
+ hasUpdates = true
+ }
+ if !hasUpdates {
+ return usage("no updates specified")
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ updated, err := svc.Matters.Update(c.MatterID, matter).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("update matter %s: %w", c.MatterID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, updated)
+ }
+
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Updated matter: %s (%s)\n", updated.Name, updated.MatterId)
+ return nil
+}
+
+type VaultMattersCloseCmd struct {
+ MatterID string `arg:"" name:"matter-id" help:"Matter ID"`
+}
+
+func (c *VaultMattersCloseCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("close matter %s", c.MatterID)); err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Matters.Close(c.MatterID, &vault.CloseMatterRequest{}).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("close matter %s: %w", c.MatterID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ matterID := c.MatterID
+ if resp != nil && resp.Matter != nil && resp.Matter.MatterId != "" {
+ matterID = resp.Matter.MatterId
+ }
+ u.Out().Printf("Closed matter: %s\n", matterID)
+ return nil
+}
+
+type VaultMattersReopenCmd struct {
+ MatterID string `arg:"" name:"matter-id" help:"Matter ID"`
+}
+
+func (c *VaultMattersReopenCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ resp, err := svc.Matters.Reopen(c.MatterID, &vault.ReopenMatterRequest{}).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("reopen matter %s: %w", c.MatterID, err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ matterID := c.MatterID
+ if resp != nil && resp.Matter != nil && resp.Matter.MatterId != "" {
+ matterID = resp.Matter.MatterId
+ }
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Reopened matter: %s\n", matterID)
+ return nil
+}
+
+type VaultMattersDeleteCmd struct {
+ MatterID string `arg:"" name:"matter-id" help:"Matter ID"`
+}
+
+func (c *VaultMattersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete matter %s", c.MatterID)); err != nil {
+ return err
+ }
+
+ svc, err := newVaultService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ if _, err := svc.Matters.Delete(c.MatterID).Context(ctx).Do(); err != nil {
+ return fmt.Errorf("delete matter %s: %w", c.MatterID, err)
+ }
+
+ u.Out().Printf("Deleted matter: %s\n", c.MatterID)
+ return nil
+}
diff --git a/internal/cmd/vault_test.go b/internal/cmd/vault_test.go
new file mode 100644
index 00000000..2ec1d2d2
--- /dev/null
+++ b/internal/cmd/vault_test.go
@@ -0,0 +1,556 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/option"
+ "google.golang.org/api/storage/v1"
+ "google.golang.org/api/vault/v1"
+)
+
+func TestVaultMattersListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/matters") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matters": []map[string]any{
+ {"matterId": "matter-1", "name": "Case One", "state": "OPEN"},
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultMattersListCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Case One") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultExportsDownloadCmd(t *testing.T) {
+ vaultHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/exports/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "export-1",
+ "name": "Export One",
+ "cloudStorageSink": map[string]any{
+ "files": []map[string]any{
+ {"bucketName": "vault-bucket", "objectName": "exports/export1.zip"},
+ },
+ },
+ })
+ })
+ stubVault(t, vaultHandler)
+
+ storageHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/b/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/octet-stream")
+ _, _ = w.Write([]byte("vault-export"))
+ })
+ stubStorage(t, storageHandler)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultExportsDownloadCmd{MatterID: "matter-1", ExportID: "export-1", Output: t.TempDir()}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ path := filepath.Join(cmd.Output, "export1.zip")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read export: %v", err)
+ }
+ if string(data) != "vault-export" {
+ t.Fatalf("unexpected file contents: %s", string(data))
+ }
+ if !strings.Contains(out, "Downloaded") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func stubVault(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newVaultService
+ svc, err := vault.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new vault service: %v", err)
+ }
+ newVaultService = func(context.Context, string) (*vault.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newVaultService = orig
+ srv.Close()
+ })
+}
+
+func stubStorage(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newStorageService
+ svc, err := storage.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new storage service: %v", err)
+ }
+ newStorageService = func(context.Context, string) (*storage.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newStorageService = orig
+ srv.Close()
+ })
+}
+
+func TestVaultMattersGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/matters/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matterId": "matter-1",
+ "name": "Test Matter",
+ "state": "OPEN",
+ "description": "Test description",
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultMattersGetCmd{MatterID: "matter-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Test Matter") || !strings.Contains(out, "OPEN") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultMattersCreateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/matters") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matterId": "new-matter",
+ "name": "New Matter",
+ "state": "OPEN",
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultMattersCreateCmd{Name: "New Matter", Description: "Test"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created matter") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultMattersUpdateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/matters/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matterId": "matter-1",
+ "name": "Updated Matter",
+ "state": "OPEN",
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ newName := "Updated Matter"
+ cmd := &VaultMattersUpdateCmd{MatterID: "matter-1", Name: &newName}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Updated matter") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultMattersCloseCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":close") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matter": map[string]any{
+ "matterId": "matter-1",
+ "name": "Closed Matter",
+ "state": "CLOSED",
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &VaultMattersCloseCmd{MatterID: "matter-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Closed matter") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultMattersReopenCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":reopen") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matter": map[string]any{
+ "matterId": "matter-1",
+ "name": "Reopened Matter",
+ "state": "OPEN",
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultMattersReopenCmd{MatterID: "matter-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Reopened matter") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultMattersDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/matters/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "matterId": "matter-1",
+ "state": "DELETED",
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &VaultMattersDeleteCmd{MatterID: "matter-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted matter") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultExportsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/exports") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "exports": []map[string]any{
+ {
+ "id": "export-1",
+ "name": "Test Export",
+ "status": "COMPLETED",
+ "createTime": "2024-01-01T00:00:00Z",
+ },
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultExportsListCmd{MatterID: "matter-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Test Export") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultExportsGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/exports/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "export-1",
+ "name": "Export Details",
+ "status": "COMPLETED",
+ "createTime": "2024-01-01T00:00:00Z",
+ "query": map[string]any{
+ "corpus": "MAIL",
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultExportsGetCmd{MatterID: "matter-1", ExportID: "export-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Export Details") || !strings.Contains(out, "COMPLETED") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultExportsCreateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/exports") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "new-export",
+ "name": "New Export",
+ "status": "IN_PROGRESS",
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultExportsCreateCmd{MatterID: "matter-1", Name: "New Export", Query: "test"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created export") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultHoldsListCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/holds") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "holds": []map[string]any{
+ {
+ "holdId": "hold-1",
+ "name": "Legal Hold",
+ "corpus": "MAIL",
+ "accounts": []map[string]any{
+ {"email": "user@example.com"},
+ },
+ },
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultHoldsListCmd{MatterID: "matter-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Legal Hold") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultHoldsGetCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/holds/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "holdId": "hold-1",
+ "name": "Legal Hold Details",
+ "corpus": "MAIL",
+ "orgUnit": map[string]any{
+ "orgUnitId": "ou-123",
+ },
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultHoldsGetCmd{MatterID: "matter-1", HoldID: "hold-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Legal Hold Details") || !strings.Contains(out, "org-unit") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultHoldsCreateCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/holds") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "holdId": "new-hold",
+ "name": "New Legal Hold",
+ "corpus": "MAIL",
+ })
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &VaultHoldsCreateCmd{MatterID: "matter-1", Name: "New Legal Hold", Corpus: "MAIL", Accounts: "user@example.com"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Created hold") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestVaultHoldsDeleteCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/holds/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(map[string]any{})
+ })
+ stubVault(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &VaultHoldsDeleteCmd{MatterID: "matter-1", HoldID: "hold-1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Deleted hold") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestHoldScope(t *testing.T) {
+ tests := []struct {
+ name string
+ hold *vault.Hold
+ expected string
+ }{
+ {"nil hold", nil, ""},
+ {"empty hold", &vault.Hold{}, ""},
+ {"with org unit", &vault.Hold{OrgUnit: &vault.HeldOrgUnit{OrgUnitId: "ou-1"}}, "org-unit"},
+ {"with accounts", &vault.Hold{Accounts: []*vault.HeldAccount{{Email: "a@b.c"}, {Email: "d@e.f"}}}, "accounts:2"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := holdScope(tt.hold)
+ if result != tt.expected {
+ t.Errorf("holdScope() = %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go
new file mode 100644
index 00000000..2e38e18e
--- /dev/null
+++ b/internal/cmd/youtube.go
@@ -0,0 +1,141 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+var newYouTubeService = googleapi.NewYouTube
+
+type YouTubeCmd struct {
+ Accounts YouTubeAccountsCmd `cmd:"" name:"accounts" help:"List YouTube accounts (managed channels)"`
+ Channels YouTubeChannelsCmd `cmd:"" name:"channels" help:"List YouTube channels (owned by user)"`
+}
+
+type YouTubeAccountsCmd struct {
+ User string `name:"user" help:"User email to list accounts for"`
+ Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *YouTubeAccountsCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(c.User) != "" {
+ account = strings.TrimSpace(c.User)
+ }
+
+ svc, err := newYouTubeService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Channels.List([]string{"snippet"}).ManagedByMe(true)
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list youtube accounts: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Items) == 0 {
+ ui.FromContext(ctx).Err().Println("No YouTube accounts found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "CHANNEL ID\tTITLE\tCUSTOM URL")
+ for _, ch := range resp.Items {
+ if ch == nil {
+ continue
+ }
+ title := ""
+ customURL := ""
+ if ch.Snippet != nil {
+ title = ch.Snippet.Title
+ customURL = ch.Snippet.CustomUrl
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", sanitizeTab(ch.Id), sanitizeTab(title), sanitizeTab(customURL))
+ }
+ printNextPageHint(ui.FromContext(ctx), resp.NextPageToken)
+ return nil
+}
+
+type YouTubeChannelsCmd struct {
+ User string `name:"user" help:"User email to list channels for"`
+ Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"`
+ Page string `name:"page" help:"Page token"`
+}
+
+func (c *YouTubeChannelsCmd) Run(ctx context.Context, flags *RootFlags) error {
+ account, err := requireAccount(flags)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(c.User) != "" {
+ account = strings.TrimSpace(c.User)
+ }
+
+ svc, err := newYouTubeService(ctx, account)
+ if err != nil {
+ return err
+ }
+
+ call := svc.Channels.List([]string{"snippet"}).Mine(true)
+ if c.Max > 0 {
+ call = call.MaxResults(c.Max)
+ }
+ if c.Page != "" {
+ call = call.PageToken(c.Page)
+ }
+
+ resp, err := call.Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("list youtube channels: %w", err)
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Items) == 0 {
+ ui.FromContext(ctx).Err().Println("No YouTube channels found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "CHANNEL ID\tTITLE\tCUSTOM URL")
+ for _, ch := range resp.Items {
+ if ch == nil {
+ continue
+ }
+ title := ""
+ customURL := ""
+ if ch.Snippet != nil {
+ title = ch.Snippet.Title
+ customURL = ch.Snippet.CustomUrl
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", sanitizeTab(ch.Id), sanitizeTab(title), sanitizeTab(customURL))
+ }
+ printNextPageHint(ui.FromContext(ctx), resp.NextPageToken)
+ return nil
+}
diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go
new file mode 100644
index 00000000..9c2fbd81
--- /dev/null
+++ b/internal/cmd/youtube_test.go
@@ -0,0 +1,91 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/option"
+ "google.golang.org/api/youtube/v3"
+)
+
+func TestYouTubeAccountsCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/youtube/v3/channels") || r.URL.Query().Get("managedByMe") != "true" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"id": "ch1", "snippet": map[string]any{"title": "Brand", "customUrl": "brand"}},
+ },
+ })
+ })
+ stubYouTube(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &YouTubeAccountsCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Brand") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestYouTubeChannelsCmd(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/youtube/v3/channels") || r.URL.Query().Get("mine") != "true" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "items": []map[string]any{
+ {"id": "ch2", "snippet": map[string]any{"title": "Owner", "customUrl": "owner"}},
+ },
+ })
+ })
+ stubYouTube(t, h)
+
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &YouTubeChannelsCmd{}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Owner") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func stubYouTube(t *testing.T, handler http.Handler) {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newYouTubeService
+ svc, err := youtube.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("new youtube service: %v", err)
+ }
+ newYouTubeService = func(context.Context, string) (*youtube.Service, error) { return svc, nil }
+ t.Cleanup(func() {
+ newYouTubeService = orig
+ srv.Close()
+ })
+}
diff --git a/internal/csv/processor.go b/internal/csv/processor.go
new file mode 100644
index 00000000..040c5301
--- /dev/null
+++ b/internal/csv/processor.go
@@ -0,0 +1,307 @@
+package csv
+
+import (
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strings"
+)
+
+var (
+ errEmptyCSV = errors.New("empty csv")
+ errFileRequired = errors.New("file is required")
+ errInvalidToken = errors.New("invalid replacement token")
+ errInvalidFilterFormat = errors.New("invalid filter format")
+ errInvalidFilterField = errors.New("invalid filter field")
+)
+
+type FieldFilter struct {
+ Field string
+ Regex *regexp.Regexp
+}
+
+type Options struct {
+ Fields []string
+ Match []FieldFilter
+ Skip []FieldFilter
+ SkipRows int
+ MaxRows int
+}
+
+type Row struct {
+ Index int
+ Values map[string]string
+}
+
+func Process(path string, opts Options, fn func(Row) error) error {
+ reader, closer, err := openCSV(path)
+ if err != nil {
+ return err
+ }
+
+ if closer != nil {
+ defer closer.Close()
+ }
+
+ records, err := reader.ReadAll()
+ if err != nil {
+ return fmt.Errorf("read csv: %w", err)
+ }
+
+ if len(records) == 0 {
+ return errEmptyCSV
+ }
+
+ headers := normalizeHeader(records[0])
+ selected := normalizeFields(opts.Fields)
+
+ processed := 0
+
+ for i, row := range records[1:] {
+ rowIndex := i + 1
+ if opts.SkipRows > 0 && rowIndex <= opts.SkipRows {
+ continue
+ }
+
+ values := mapRow(headers, row, selected)
+ if !matchesAllFilters(values, opts.Match) {
+ continue
+ }
+
+ if matchesAnyFilter(values, opts.Skip) {
+ continue
+ }
+
+ processed++
+ if opts.MaxRows > 0 && processed > opts.MaxRows {
+ break
+ }
+
+ if err := fn(Row{Index: rowIndex, Values: values}); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func SubstituteArgs(args []string, row Row) ([]string, error) {
+ out := make([]string, len(args))
+ for i, arg := range args {
+ sub, err := substituteArg(arg, row)
+ if err != nil {
+ return nil, err
+ }
+ out[i] = sub
+ }
+
+ return out, nil
+}
+
+func openCSV(path string) (*csv.Reader, io.Closer, error) {
+ trimmed := strings.TrimSpace(path)
+ if trimmed == "" {
+ return nil, nil, errFileRequired
+ }
+
+ if trimmed == "-" {
+ return csv.NewReader(os.Stdin), nil, nil
+ }
+
+ f, err := os.Open(trimmed) //nolint:gosec // G304: user-provided file path is intentional
+ if err != nil {
+ return nil, nil, fmt.Errorf("open csv: %w", err)
+ }
+
+ return csv.NewReader(f), f, nil
+}
+
+func normalizeHeader(header []string) []string {
+ out := make([]string, len(header))
+ for i, h := range header {
+ out[i] = normalizeField(h)
+ }
+
+ return out
+}
+
+func normalizeFields(fields []string) map[string]struct{} {
+ if len(fields) == 0 {
+ return nil
+ }
+
+ set := make(map[string]struct{}, len(fields))
+ for _, f := range fields {
+ if f = normalizeField(f); f != "" {
+ set[f] = struct{}{}
+ }
+ }
+
+ return set
+}
+
+func normalizeField(field string) string {
+ return strings.ToLower(strings.TrimSpace(field))
+}
+
+func mapRow(headers, row []string, allowed map[string]struct{}) map[string]string {
+ values := make(map[string]string, len(headers))
+ for i, key := range headers {
+ if key == "" {
+ continue
+ }
+
+ if allowed != nil {
+ if _, ok := allowed[key]; !ok {
+ continue
+ }
+ }
+
+ if i >= len(row) {
+ values[key] = ""
+ continue
+ }
+ values[key] = strings.TrimSpace(row[i])
+ }
+
+ return values
+}
+
+func matchesAllFilters(values map[string]string, filters []FieldFilter) bool {
+ if len(filters) == 0 {
+ return true
+ }
+
+ for _, filter := range filters {
+ value := values[filter.Field]
+ if filter.Regex == nil {
+ continue
+ }
+
+ if !filter.Regex.MatchString(value) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func matchesAnyFilter(values map[string]string, filters []FieldFilter) bool {
+ if len(filters) == 0 {
+ return false
+ }
+
+ for _, filter := range filters {
+ value := values[filter.Field]
+ if filter.Regex == nil {
+ continue
+ }
+
+ if filter.Regex.MatchString(value) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func substituteArg(arg string, row Row) (string, error) {
+ if strings.Contains(arg, "~~") {
+ replaced, err := replaceDoubleTilde(arg, row)
+ if err != nil {
+ return "", err
+ }
+
+ return replaced, nil
+ }
+
+ if strings.HasPrefix(arg, "~") {
+ field := normalizeField(strings.TrimPrefix(arg, "~"))
+ if field == "" {
+ return "", nil
+ }
+
+ return row.Values[field], nil
+ }
+
+ return arg, nil
+}
+
+func replaceDoubleTilde(input string, row Row) (string, error) {
+ out := input
+ for {
+ start := strings.Index(out, "~~")
+ if start == -1 {
+ return out, nil
+ }
+ rest := out[start+2:]
+
+ end := strings.Index(rest, "~~")
+ if end == -1 {
+ return out, nil
+ }
+ token := rest[:end]
+
+ replacement, err := resolveToken(token, row)
+ if err != nil {
+ return "", err
+ }
+ out = out[:start] + replacement + rest[end+2:]
+ }
+}
+
+func resolveToken(token string, row Row) (string, error) {
+ if strings.Contains(token, "~!~") {
+ parts := strings.Split(token, "~!~")
+ if len(parts) != 3 {
+ return "", fmt.Errorf("%w: %s", errInvalidToken, token)
+ }
+ field := normalizeField(parts[0])
+ pattern := parts[1]
+ repl := parts[2]
+ value := row.Values[field]
+
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return "", fmt.Errorf("invalid regex %q: %w", pattern, err)
+ }
+
+ return re.ReplaceAllString(value, repl), nil
+ }
+
+ field := normalizeField(token)
+
+ return row.Values[field], nil
+}
+
+func ParseFieldFilters(inputs []string) ([]FieldFilter, error) {
+ filters := make([]FieldFilter, 0, len(inputs))
+ for _, item := range inputs {
+ trimmed := strings.TrimSpace(item)
+ if trimmed == "" {
+ continue
+ }
+
+ parts := strings.SplitN(trimmed, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("%w: %q (expected FIELD:REGEX)", errInvalidFilterFormat, item)
+ }
+
+ field := normalizeField(parts[0])
+ if field == "" {
+ return nil, fmt.Errorf("%w: %q (missing field)", errInvalidFilterField, item)
+ }
+
+ re, err := regexp.Compile(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("invalid filter regex %q: %w", parts[1], err)
+ }
+
+ filters = append(filters, FieldFilter{Field: field, Regex: re})
+ }
+
+ return filters, nil
+}
diff --git a/internal/csv/processor_test.go b/internal/csv/processor_test.go
new file mode 100644
index 00000000..37f34cf1
--- /dev/null
+++ b/internal/csv/processor_test.go
@@ -0,0 +1,1245 @@
+package csv
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+var errCallbackFailed = errors.New("callback failed")
+
+// Helper to create temp CSV files for testing
+func createTempCSV(t *testing.T, content string) string {
+ t.Helper()
+
+ f, err := os.CreateTemp(t.TempDir(), "test-*.csv")
+ if err != nil {
+ t.Fatalf("create temp file: %v", err)
+ }
+ defer f.Close()
+
+ if _, err := f.WriteString(content); err != nil {
+ t.Fatalf("write temp file: %v", err)
+ }
+
+ return f.Name()
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SubstituteArgs Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestSubstituteArgs_SimpleSubstitution(t *testing.T) {
+ row := Row{
+ Index: 1,
+ Values: map[string]string{
+ "email": "user@example.com",
+ "firstname": "John",
+ "lastname": "Doe",
+ },
+ }
+
+ tests := []struct {
+ name string
+ args []string
+ want []string
+ }{
+ {
+ name: "single field",
+ args: []string{"~email"},
+ want: []string{"user@example.com"},
+ },
+ {
+ name: "multiple fields",
+ args: []string{"~firstname", "~lastname"},
+ want: []string{"John", "Doe"},
+ },
+ {
+ name: "mixed literal and field",
+ args: []string{"--name", "~firstname", "--email", "~email"},
+ want: []string{"--name", "John", "--email", "user@example.com"},
+ },
+ {
+ name: "field not in row returns empty",
+ args: []string{"~missing"},
+ want: []string{""},
+ },
+ {
+ name: "case insensitive field name",
+ args: []string{"~EMAIL", "~FirstName"},
+ want: []string{"user@example.com", "John"},
+ },
+ {
+ name: "empty tilde returns empty",
+ args: []string{"~"},
+ want: []string{""},
+ },
+ {
+ name: "tilde with spaces normalizes",
+ args: []string{"~ email "},
+ want: []string{"user@example.com"},
+ },
+ {
+ name: "no substitution for literal",
+ args: []string{"literal", "text"},
+ want: []string{"literal", "text"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := SubstituteArgs(tt.args, row)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %d args, want %d", len(got), len(tt.want))
+ }
+
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("arg[%d]: got %q, want %q", i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestSubstituteArgs_AdvancedSubstitution(t *testing.T) {
+ row := Row{
+ Index: 1,
+ Values: map[string]string{
+ "email": "user@example.com",
+ "firstname": "John",
+ "lastname": "Doe",
+ },
+ }
+
+ tests := []struct {
+ name string
+ args []string
+ want []string
+ }{
+ {
+ name: "double tilde substitution",
+ args: []string{"Hello ~~firstname~~!"},
+ want: []string{"Hello John!"},
+ },
+ {
+ name: "multiple double tilde in single arg",
+ args: []string{"~~firstname~~ ~~lastname~~"},
+ want: []string{"John Doe"},
+ },
+ {
+ name: "double tilde in quoted string",
+ args: []string{`"Name: ~~firstname~~ ~~lastname~~"`},
+ want: []string{`"Name: John Doe"`},
+ },
+ {
+ name: "double tilde missing field returns empty",
+ args: []string{"Hello ~~missing~~!"},
+ want: []string{"Hello !"},
+ },
+ {
+ name: "unclosed double tilde left as is",
+ args: []string{"Hello ~~firstname"},
+ want: []string{"Hello ~~firstname"},
+ },
+ {
+ name: "single tilde inside double tilde context",
+ args: []string{"prefix ~~email~~ suffix"},
+ want: []string{"prefix user@example.com suffix"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := SubstituteArgs(tt.args, row)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %d args, want %d", len(got), len(tt.want))
+ }
+
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("arg[%d]: got %q, want %q", i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestSubstituteArgs_RegexReplacement(t *testing.T) {
+ row := Row{
+ Index: 1,
+ Values: map[string]string{
+ "email": "user@example.com",
+ "phone": "+1-555-123-4567",
+ "name": "John Doe",
+ "country": "USA",
+ },
+ }
+
+ tests := []struct {
+ name string
+ args []string
+ want []string
+ wantErr bool
+ }{
+ {
+ name: "regex with replacement string",
+ args: []string{"~~email~!~@example.com~!~@company.org~~"},
+ want: []string{"user@company.org"},
+ },
+ {
+ name: "capture group replacement",
+ args: []string{"~~name~!~(\\w+) (\\w+)~!~$2, $1~~"},
+ want: []string{"Doe, John"},
+ },
+ {
+ name: "regex no match leaves unchanged",
+ args: []string{"~~country~!~XXX~!~YYY~~"},
+ want: []string{"USA"},
+ },
+ {
+ name: "regex replace with single char",
+ args: []string{"~~phone~!~[^0-9]~!~_~~"},
+ want: []string{"_1_555_123_4567"},
+ },
+ {
+ name: "extract username with capture group",
+ args: []string{"~~email~!~(.*)@.*~!~$1~~"},
+ want: []string{"user"},
+ },
+ {
+ name: "multiple regex replacements in one arg",
+ args: []string{"~~name~!~ ~!~_~~ at ~~email~!~@~!~ AT ~~"},
+ want: []string{"John_Doe at user AT example.com"},
+ },
+ {
+ name: "invalid regex pattern",
+ args: []string{"~~email~!~[invalid~!~x~~"},
+ wantErr: true,
+ },
+ {
+ name: "malformed replacement token (too few parts)",
+ args: []string{"~~email~!~pattern~~"},
+ wantErr: true,
+ },
+ {
+ name: "malformed replacement token (too many parts)",
+ args: []string{"~~email~!~a~!~b~!~c~~"},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := SubstituteArgs(tt.args, row)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %d args, want %d", len(got), len(tt.want))
+ }
+
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("arg[%d]: got %q, want %q", i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestSubstituteArgs_EmptyArgs(t *testing.T) {
+ row := Row{Index: 1, Values: map[string]string{"a": "b"}}
+
+ got, err := SubstituteArgs([]string{}, row)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(got) != 0 {
+ t.Errorf("expected empty slice, got %v", got)
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ParseFieldFilters Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestParseFieldFilters_Valid(t *testing.T) {
+ tests := []struct {
+ name string
+ inputs []string
+ want []struct {
+ field string
+ pattern string
+ }
+ }{
+ {
+ name: "single filter",
+ inputs: []string{"status:active"},
+ want: []struct{ field, pattern string }{{field: "status", pattern: "active"}},
+ },
+ {
+ name: "multiple filters",
+ inputs: []string{"status:active", "role:admin"},
+ want: []struct{ field, pattern string }{
+ {field: "status", pattern: "active"},
+ {field: "role", pattern: "admin"},
+ },
+ },
+ {
+ name: "regex pattern",
+ inputs: []string{"email:.*@example\\.com$"},
+ want: []struct{ field, pattern string }{{field: "email", pattern: ".*@example\\.com$"}},
+ },
+ {
+ name: "field name normalized to lowercase",
+ inputs: []string{"Status:active", "EMAIL:test"},
+ want: []struct{ field, pattern string }{
+ {field: "status", pattern: "active"},
+ {field: "email", pattern: "test"},
+ },
+ },
+ {
+ name: "empty input list",
+ inputs: []string{},
+ want: []struct{ field, pattern string }{},
+ },
+ {
+ name: "outer whitespace trimmed field normalized",
+ inputs: []string{" status :active"},
+ want: []struct{ field, pattern string }{{field: "status", pattern: "active"}},
+ },
+ {
+ name: "empty string skipped",
+ inputs: []string{"", " ", "status:ok"},
+ want: []struct{ field, pattern string }{{field: "status", pattern: "ok"}},
+ },
+ {
+ name: "colon in regex pattern",
+ inputs: []string{"time:^\\d{2}:\\d{2}$"},
+ want: []struct{ field, pattern string }{{field: "time", pattern: "^\\d{2}:\\d{2}$"}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ParseFieldFilters(tt.inputs)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %d filters, want %d", len(got), len(tt.want))
+ }
+
+ for i, wf := range tt.want {
+ if got[i].Field != wf.field {
+ t.Errorf("filter[%d].Field: got %q, want %q", i, got[i].Field, wf.field)
+ }
+
+ if got[i].Regex.String() != wf.pattern {
+ t.Errorf("filter[%d].Regex: got %q, want %q", i, got[i].Regex.String(), wf.pattern)
+ }
+ }
+ })
+ }
+}
+
+func TestParseFieldFilters_Errors(t *testing.T) {
+ tests := []struct {
+ name string
+ inputs []string
+ wantErr string
+ }{
+ {
+ name: "missing colon",
+ inputs: []string{"statusactive"},
+ wantErr: "invalid filter",
+ },
+ {
+ name: "missing field name",
+ inputs: []string{":active"},
+ wantErr: "missing field",
+ },
+ {
+ name: "invalid regex",
+ inputs: []string{"status:[invalid"},
+ wantErr: "invalid filter regex",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := ParseFieldFilters(tt.inputs)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Errorf("error %q should contain %q", err.Error(), tt.wantErr)
+ }
+ })
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Process Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestProcess_BasicCSV(t *testing.T) {
+ csv := `email,firstName,lastName
+alice@example.com,Alice,Smith
+bob@example.com,Bob,Jones
+charlie@example.com,Charlie,Brown
+`
+ path := createTempCSV(t, csv)
+
+ var rows []Row
+
+ err := Process(path, Options{}, func(row Row) error {
+ rows = append(rows, row)
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ // Verify first row
+ if rows[0].Index != 1 {
+ t.Errorf("row[0].Index: got %d, want 1", rows[0].Index)
+ }
+
+ if rows[0].Values["email"] != "alice@example.com" {
+ t.Errorf("row[0].email: got %q", rows[0].Values["email"])
+ }
+
+ if rows[0].Values["firstname"] != "Alice" {
+ t.Errorf("row[0].firstname: got %q", rows[0].Values["firstname"])
+ }
+}
+
+func TestProcess_HeaderNormalization(t *testing.T) {
+ csv := ` Email , First Name , LAST_NAME
+test@test.com,Test,User
+`
+ path := createTempCSV(t, csv)
+
+ var row Row
+
+ err := Process(path, Options{}, func(r Row) error {
+ row = r
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // Headers should be normalized to lowercase and trimmed
+ if row.Values["email"] != "test@test.com" {
+ t.Errorf("email field not found or wrong: %v", row.Values)
+ }
+
+ if row.Values["first name"] != "Test" {
+ t.Errorf("first name field not found or wrong: %v", row.Values)
+ }
+
+ if row.Values["last_name"] != "User" {
+ t.Errorf("last_name field not found or wrong: %v", row.Values)
+ }
+}
+
+func TestProcess_FieldSelection(t *testing.T) {
+ csv := `email,name,role,status
+user@test.com,User,admin,active
+`
+ path := createTempCSV(t, csv)
+
+ var row Row
+
+ err := Process(path, Options{Fields: []string{"email", "role"}}, func(r Row) error {
+ row = r
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // Only selected fields should be present
+ if _, ok := row.Values["email"]; !ok {
+ t.Error("email field should be present")
+ }
+
+ if _, ok := row.Values["role"]; !ok {
+ t.Error("role field should be present")
+ }
+
+ if _, ok := row.Values["name"]; ok {
+ t.Error("name field should NOT be present")
+ }
+
+ if _, ok := row.Values["status"]; ok {
+ t.Error("status field should NOT be present")
+ }
+}
+
+func TestProcess_MatchFilter(t *testing.T) {
+ csv := `email,status,role
+alice@test.com,active,admin
+bob@test.com,inactive,user
+charlie@test.com,active,user
+`
+ path := createTempCSV(t, csv)
+
+ // Use anchored regex ^active$ to match exactly "active", not "inactive"
+ matchFilters, _ := ParseFieldFilters([]string{"status:^active$"})
+
+ var emails []string
+
+ err := Process(path, Options{Match: matchFilters}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 matching rows, got %d", len(emails))
+ }
+
+ if emails[0] != "alice@test.com" || emails[1] != "charlie@test.com" {
+ t.Errorf("wrong emails matched: %v", emails)
+ }
+}
+
+func TestProcess_SkipFilter(t *testing.T) {
+ csv := `email,status,role
+alice@test.com,active,admin
+bob@test.com,inactive,user
+charlie@test.com,active,user
+`
+ path := createTempCSV(t, csv)
+
+ skipFilters, _ := ParseFieldFilters([]string{"status:inactive"})
+
+ var emails []string
+
+ err := Process(path, Options{Skip: skipFilters}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 rows (skipping inactive), got %d", len(emails))
+ }
+
+ for _, e := range emails {
+ if e == "bob@test.com" {
+ t.Error("bob@test.com should have been skipped")
+ }
+ }
+}
+
+func TestProcess_MatchAndSkipCombined(t *testing.T) {
+ csv := `email,status,role
+alice@test.com,active,admin
+bob@test.com,active,user
+charlie@test.com,active,guest
+dave@test.com,inactive,admin
+`
+ path := createTempCSV(t, csv)
+
+ // Use anchored regex ^active$ to avoid matching "inactive"
+ matchFilters, _ := ParseFieldFilters([]string{"status:^active$"})
+ skipFilters, _ := ParseFieldFilters([]string{"role:^guest$"})
+
+ var emails []string
+
+ err := Process(path, Options{Match: matchFilters, Skip: skipFilters}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // Should match active users, but skip guests
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 rows, got %d: %v", len(emails), emails)
+ }
+
+ expected := map[string]bool{"alice@test.com": true, "bob@test.com": true}
+ for _, e := range emails {
+ if !expected[e] {
+ t.Errorf("unexpected email: %s", e)
+ }
+ }
+}
+
+func TestProcess_MatchWithRegex(t *testing.T) {
+ csv := `email,domain
+alice@example.com,example.com
+bob@test.org,test.org
+charlie@example.net,example.net
+`
+ path := createTempCSV(t, csv)
+
+ matchFilters, _ := ParseFieldFilters([]string{"email:@example\\."})
+
+ var emails []string
+
+ err := Process(path, Options{Match: matchFilters}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 rows matching @example., got %d", len(emails))
+ }
+}
+
+func TestProcess_SkipRows(t *testing.T) {
+ csv := `email,name
+row1@test.com,Row1
+row2@test.com,Row2
+row3@test.com,Row3
+row4@test.com,Row4
+`
+ path := createTempCSV(t, csv)
+
+ var emails []string
+
+ err := Process(path, Options{SkipRows: 2}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 rows after skipping 2, got %d", len(emails))
+ }
+
+ if emails[0] != "row3@test.com" {
+ t.Errorf("first email should be row3@test.com, got %s", emails[0])
+ }
+}
+
+func TestProcess_MaxRows(t *testing.T) {
+ csv := `email,name
+row1@test.com,Row1
+row2@test.com,Row2
+row3@test.com,Row3
+row4@test.com,Row4
+`
+ path := createTempCSV(t, csv)
+
+ var count int
+
+ err := Process(path, Options{MaxRows: 2}, func(row Row) error {
+ count++
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if count != 2 {
+ t.Errorf("expected 2 rows with MaxRows=2, got %d", count)
+ }
+}
+
+func TestProcess_SkipAndMaxRows(t *testing.T) {
+ csv := `email,name
+row1@test.com,Row1
+row2@test.com,Row2
+row3@test.com,Row3
+row4@test.com,Row4
+row5@test.com,Row5
+`
+ path := createTempCSV(t, csv)
+
+ var emails []string
+
+ err := Process(path, Options{SkipRows: 2, MaxRows: 2}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 rows, got %d", len(emails))
+ }
+
+ if emails[0] != "row3@test.com" || emails[1] != "row4@test.com" {
+ t.Errorf("wrong emails: %v", emails)
+ }
+}
+
+func TestProcess_CallbackError(t *testing.T) {
+ csv := `email,name
+alice@test.com,Alice
+bob@test.com,Bob
+`
+ path := createTempCSV(t, csv)
+
+ var count int
+ err := Process(path, Options{}, func(row Row) error {
+ count++
+ if count == 1 {
+ return errCallbackFailed
+ }
+
+ return nil
+ })
+
+ if !errors.Is(err, errCallbackFailed) {
+ t.Errorf("expected callback error, got: %v", err)
+ }
+
+ if count != 1 {
+ t.Errorf("callback should have been called once, got %d", count)
+ }
+}
+
+func TestProcess_EmptyCSV(t *testing.T) {
+ path := createTempCSV(t, "")
+
+ err := Process(path, Options{}, func(row Row) error {
+ return nil
+ })
+ if err == nil || !strings.Contains(err.Error(), "empty csv") {
+ t.Errorf("expected empty csv error, got: %v", err)
+ }
+}
+
+func TestProcess_HeaderOnly(t *testing.T) {
+ csv := `email,name,status
+`
+ path := createTempCSV(t, csv)
+
+ var count int
+
+ err := Process(path, Options{}, func(row Row) error {
+ count++
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if count != 0 {
+ t.Errorf("expected 0 rows (header only), got %d", count)
+ }
+}
+
+func TestProcess_FileNotFound(t *testing.T) {
+ err := Process("/nonexistent/path.csv", Options{}, func(row Row) error {
+ return nil
+ })
+ if err == nil {
+ t.Error("expected error for nonexistent file")
+ }
+}
+
+func TestProcess_EmptyPath(t *testing.T) {
+ err := Process("", Options{}, func(row Row) error {
+ return nil
+ })
+ if err == nil || !strings.Contains(err.Error(), "file is required") {
+ t.Errorf("expected 'file is required' error, got: %v", err)
+ }
+}
+
+func TestProcess_WhitespacePath(t *testing.T) {
+ err := Process(" ", Options{}, func(row Row) error {
+ return nil
+ })
+ if err == nil || !strings.Contains(err.Error(), "file is required") {
+ t.Errorf("expected 'file is required' error, got: %v", err)
+ }
+}
+
+func TestProcess_ShortRowFails(t *testing.T) {
+ // Go's CSV reader by default requires consistent field counts
+ // Row with fewer columns than header should fail
+ csv := `email,name,role
+alice@test.com,Alice
+`
+ path := createTempCSV(t, csv)
+
+ err := Process(path, Options{}, func(r Row) error {
+ return nil
+ })
+
+ // Should fail because CSV reader enforces consistent field counts
+ if err == nil {
+ t.Error("expected error for short row")
+ }
+
+ if !strings.Contains(err.Error(), "wrong number of fields") {
+ t.Errorf("expected 'wrong number of fields' error, got: %v", err)
+ }
+}
+
+func TestProcess_ValueTrimming(t *testing.T) {
+ csv := `email,name
+ alice@test.com , Alice
+`
+ path := createTempCSV(t, csv)
+
+ var row Row
+
+ err := Process(path, Options{}, func(r Row) error {
+ row = r
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // Values should be trimmed
+ if row.Values["email"] != "alice@test.com" {
+ t.Errorf("email not trimmed: %q", row.Values["email"])
+ }
+
+ if row.Values["name"] != "Alice" {
+ t.Errorf("name not trimmed: %q", row.Values["name"])
+ }
+}
+
+func TestProcess_EmptyHeaderColumn(t *testing.T) {
+ // Empty column header should be skipped
+ csv := `email,,name
+alice@test.com,ignored,Alice
+`
+ path := createTempCSV(t, csv)
+
+ var row Row
+
+ err := Process(path, Options{}, func(r Row) error {
+ row = r
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if _, ok := row.Values[""]; ok {
+ t.Error("empty header column should not create a value")
+ }
+
+ if row.Values["email"] != "alice@test.com" {
+ t.Errorf("wrong email: %q", row.Values["email"])
+ }
+
+ if row.Values["name"] != "Alice" {
+ t.Errorf("wrong name: %q", row.Values["name"])
+ }
+}
+
+func TestProcess_RowIndexing(t *testing.T) {
+ csv := `email
+row1@test.com
+row2@test.com
+row3@test.com
+`
+ path := createTempCSV(t, csv)
+
+ var indices []int
+
+ err := Process(path, Options{}, func(row Row) error {
+ indices = append(indices, row.Index)
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // Row indices should be 1, 2, 3 (1-based, after header)
+ expected := []int{1, 2, 3}
+ for i, want := range expected {
+ if indices[i] != want {
+ t.Errorf("indices[%d]: got %d, want %d", i, indices[i], want)
+ }
+ }
+}
+
+func TestProcess_MalformedCSV(t *testing.T) {
+ // Create a file with malformed CSV (unmatched quotes)
+ dir := t.TempDir()
+ path := filepath.Join(dir, "malformed.csv")
+ // Unbalanced quotes cause csv.ReadAll to fail
+ err := os.WriteFile(path, []byte(`email,name
+"alice@test.com,"Alice
+`), 0o644)
+ if err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+
+ err = Process(path, Options{}, func(row Row) error {
+ return nil
+ })
+ if err == nil {
+ t.Error("expected error for malformed CSV")
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Integration: SubstituteArgs with Process
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestIntegration_SubstituteWithProcess(t *testing.T) {
+ csv := `email,firstName,lastName,dept
+alice@example.com,Alice,Smith,Engineering
+bob@example.com,Bob,Jones,Sales
+`
+ path := createTempCSV(t, csv)
+
+ template := []string{
+ "--email", "~email",
+ "--name", "~~firstName~~ ~~lastName~~",
+ "--dept", "~~dept~!~Engineering~!~Eng~~",
+ }
+
+ var results [][]string
+
+ err := Process(path, Options{}, func(row Row) error {
+ args, err := SubstituteArgs(template, row)
+ if err != nil {
+ return err
+ }
+
+ results = append(results, args)
+
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(results) != 2 {
+ t.Fatalf("expected 2 results, got %d", len(results))
+ }
+
+ // First row
+ expected1 := []string{"--email", "alice@example.com", "--name", "Alice Smith", "--dept", "Eng"}
+ for i, want := range expected1 {
+ if results[0][i] != want {
+ t.Errorf("results[0][%d]: got %q, want %q", i, results[0][i], want)
+ }
+ }
+
+ // Second row (Sales stays unchanged)
+ if results[1][5] != "Sales" {
+ t.Errorf("results[1][5]: got %q, want %q", results[1][5], "Sales")
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Additional Edge Case Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestProcess_MultipleMatchFilters(t *testing.T) {
+ csv := `email,status,role
+alice@test.com,active,admin
+bob@test.com,active,user
+charlie@test.com,inactive,admin
+dave@test.com,active,guest
+`
+ path := createTempCSV(t, csv)
+
+ // Both filters must match (AND logic)
+ matchFilters, _ := ParseFieldFilters([]string{"status:^active$", "role:^admin$"})
+
+ var emails []string
+
+ err := Process(path, Options{Match: matchFilters}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // Only alice matches both active AND admin
+ if len(emails) != 1 || emails[0] != "alice@test.com" {
+ t.Errorf("expected only alice, got: %v", emails)
+ }
+}
+
+func TestProcess_MultipleSkipFilters(t *testing.T) {
+ csv := `email,status,role
+alice@test.com,active,admin
+bob@test.com,inactive,user
+charlie@test.com,suspended,admin
+dave@test.com,active,guest
+`
+ path := createTempCSV(t, csv)
+
+ // Skip if any filter matches (OR logic)
+ skipFilters, _ := ParseFieldFilters([]string{"status:inactive", "status:suspended"})
+
+ var emails []string
+
+ err := Process(path, Options{Skip: skipFilters}, func(row Row) error {
+ emails = append(emails, row.Values["email"])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // alice and dave should remain (bob=inactive, charlie=suspended skipped)
+ if len(emails) != 2 {
+ t.Fatalf("expected 2 rows, got %d: %v", len(emails), emails)
+ }
+}
+
+func TestProcess_NilRegexInFilter(t *testing.T) {
+ // Test that nil Regex in filter is handled gracefully
+ csv := `email,status
+alice@test.com,active
+`
+ path := createTempCSV(t, csv)
+
+ // Create filter with nil regex manually
+ nilFilter := FieldFilter{Field: "status", Regex: nil}
+
+ var count int
+
+ err := Process(path, Options{Match: []FieldFilter{nilFilter}}, func(row Row) error {
+ count++
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ // With nil regex, filter should be skipped (passes by default)
+ if count != 1 {
+ t.Errorf("expected 1 row (nil filter ignored), got %d", count)
+ }
+}
+
+func TestSubstituteArgs_NestedTildes(t *testing.T) {
+ row := Row{
+ Index: 1,
+ Values: map[string]string{
+ "path": "/home/~user/files",
+ },
+ }
+
+ // Value contains literal tilde - should be preserved
+ args, err := SubstituteArgs([]string{"~~path~~"}, row)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if args[0] != "/home/~user/files" {
+ t.Errorf("tilde in value should be preserved, got: %s", args[0])
+ }
+}
+
+func TestSubstituteArgs_EmptyRow(t *testing.T) {
+ row := Row{Index: 1, Values: map[string]string{}}
+
+ args, err := SubstituteArgs([]string{"~missing", "~~also_missing~~"}, row)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if args[0] != "" || args[1] != "" {
+ t.Errorf("missing fields should return empty strings, got: %v", args)
+ }
+}
+
+func TestParseFieldFilters_ComplexRegex(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ pattern string
+ }{
+ {
+ name: "email regex with escaped dots",
+ input: "email:^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
+ pattern: "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
+ },
+ {
+ name: "phone regex",
+ input: "phone:^\\+?[0-9]{10,14}$",
+ pattern: "^\\+?[0-9]{10,14}$",
+ },
+ {
+ name: "date regex",
+ input: "date:^\\d{4}-\\d{2}-\\d{2}$",
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ filters, err := ParseFieldFilters([]string{tt.input})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(filters) != 1 {
+ t.Fatalf("expected 1 filter, got %d", len(filters))
+ }
+
+ if filters[0].Regex.String() != tt.pattern {
+ t.Errorf("pattern mismatch: got %q, want %q", filters[0].Regex.String(), tt.pattern)
+ }
+ })
+ }
+}
+
+func TestProcess_SpecialCharactersInCSV(t *testing.T) {
+ // CSV with special characters that need proper handling
+ csv := `email,note,tags
+alice@test.com,"Contains, commas",tag1|tag2
+bob@test.com,"Has ""quotes""",tag3
+charlie@test.com,Normal text,tag4|tag5|tag6
+`
+ path := createTempCSV(t, csv)
+
+ var rows []Row
+
+ err := Process(path, Options{}, func(row Row) error {
+ rows = append(rows, row)
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ // Check that commas inside quotes are preserved
+ if rows[0].Values["note"] != "Contains, commas" {
+ t.Errorf("row[0].note: got %q, want %q", rows[0].Values["note"], "Contains, commas")
+ }
+
+ // Check that escaped quotes are unescaped
+ if rows[1].Values["note"] != `Has "quotes"` {
+ t.Errorf("row[1].note: got %q, want %q", rows[1].Values["note"], `Has "quotes"`)
+ }
+}
+
+func TestProcess_UnicodeInCSV(t *testing.T) {
+ csv := `name,city,emoji
+日本語,東京,🎌
+Español,México,🇲🇽
+Français,Paris,🇫🇷
+`
+ path := createTempCSV(t, csv)
+
+ var rows []Row
+
+ err := Process(path, Options{}, func(row Row) error {
+ rows = append(rows, row)
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ if rows[0].Values["name"] != "日本語" || rows[0].Values["city"] != "東京" {
+ t.Errorf("Unicode not preserved in row[0]: %v", rows[0].Values)
+ }
+
+ if rows[0].Values["emoji"] != "🎌" {
+ t.Errorf("Emoji not preserved: got %q", rows[0].Values["emoji"])
+ }
+}
+
+func TestProcess_ReadFromStdin(t *testing.T) {
+ csv := `email,name,status
+alice@test.com,Alice,active
+bob@test.com,Bob,inactive
+`
+
+ // Create a pipe to simulate stdin
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("create pipe: %v", err)
+ }
+ defer r.Close()
+
+ // Write CSV data to the pipe
+ go func() {
+ defer w.Close()
+
+ if _, writeErr := w.WriteString(csv); writeErr != nil {
+ t.Errorf("write to pipe: %v", writeErr)
+ }
+ }()
+
+ // Temporarily replace stdin
+ oldStdin := os.Stdin
+ os.Stdin = r
+
+ defer func() { os.Stdin = oldStdin }()
+
+ var rows []Row
+
+ err = Process("-", Options{}, func(row Row) error {
+ rows = append(rows, row)
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Process error: %v", err)
+ }
+
+ if len(rows) != 2 {
+ t.Fatalf("expected 2 rows, got %d", len(rows))
+ }
+
+ if rows[0].Values["email"] != "alice@test.com" {
+ t.Errorf("row[0].email: got %q, want %q", rows[0].Values["email"], "alice@test.com")
+ }
+
+ if rows[1].Values["name"] != "Bob" {
+ t.Errorf("row[1].name: got %q, want %q", rows[1].Values["name"], "Bob")
+ }
+}
diff --git a/internal/googleapi/accesscontext.go b/internal/googleapi/accesscontext.go
new file mode 100644
index 00000000..b312d815
--- /dev/null
+++ b/internal/googleapi/accesscontext.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/accesscontextmanager/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewAccessContextManager(ctx context.Context, email string) (*accesscontextmanager.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceAccessContext, email)
+ if err != nil {
+ return nil, fmt.Errorf("access context options: %w", err)
+ }
+
+ svc, err := accesscontextmanager.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create access context service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/admin_directory.go b/internal/googleapi/admin_directory.go
new file mode 100644
index 00000000..21078e26
--- /dev/null
+++ b/internal/googleapi/admin_directory.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ admin "google.golang.org/api/admin/directory/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewAdminDirectory(ctx context.Context, email string) (*admin.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceAdminDirectory, email)
+ if err != nil {
+ return nil, fmt.Errorf("admin directory options: %w", err)
+ }
+
+ svc, err := admin.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create admin directory service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/alertcenter.go b/internal/googleapi/alertcenter.go
new file mode 100644
index 00000000..4fa5ea26
--- /dev/null
+++ b/internal/googleapi/alertcenter.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ alertcenter "google.golang.org/api/alertcenter/v1beta1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewAlertCenter(ctx context.Context, email string) (*alertcenter.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceAlertCenter, email)
+ if err != nil {
+ return nil, fmt.Errorf("alertcenter options: %w", err)
+ }
+
+ svc, err := alertcenter.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create alertcenter service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/analytics.go b/internal/googleapi/analytics.go
new file mode 100644
index 00000000..779b90a8
--- /dev/null
+++ b/internal/googleapi/analytics.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ analyticsadmin "google.golang.org/api/analyticsadmin/v1beta"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewAnalyticsAdmin(ctx context.Context, email string) (*analyticsadmin.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceAnalytics, email)
+ if err != nil {
+ return nil, fmt.Errorf("analytics admin options: %w", err)
+ }
+
+ svc, err := analyticsadmin.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create analytics admin service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/cloudchannel.go b/internal/googleapi/cloudchannel.go
new file mode 100644
index 00000000..6ce5cd72
--- /dev/null
+++ b/internal/googleapi/cloudchannel.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/cloudchannel/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewCloudChannel(ctx context.Context, email string) (*cloudchannel.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceCloudChannel, email)
+ if err != nil {
+ return nil, fmt.Errorf("cloud channel options: %w", err)
+ }
+
+ svc, err := cloudchannel.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create cloud channel service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/cloudidentity.go b/internal/googleapi/cloudidentity.go
index d982f01d..0f0e9369 100644
--- a/internal/googleapi/cloudidentity.go
+++ b/internal/googleapi/cloudidentity.go
@@ -5,6 +5,8 @@ import (
"fmt"
"google.golang.org/api/cloudidentity/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
)
const (
@@ -22,3 +24,33 @@ func NewCloudIdentityGroups(ctx context.Context, email string) (*cloudidentity.S
return svc, nil
}
}
+
+// NewCloudIdentityInboundSSO creates a Cloud Identity service for inbound SSO administration.
+func NewCloudIdentityInboundSSO(ctx context.Context, email string) (*cloudidentity.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceInboundSSO, email)
+ if err != nil {
+ return nil, fmt.Errorf("cloudidentity inbound sso options: %w", err)
+ }
+
+ svc, err := cloudidentity.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create cloudidentity inbound sso service: %w", err)
+ }
+
+ return svc, nil
+}
+
+// NewCloudIdentity creates a Cloud Identity service for admin-level group and policy management.
+func NewCloudIdentity(ctx context.Context, email string) (*cloudidentity.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceCloudIdentity, email)
+ if err != nil {
+ return nil, fmt.Errorf("cloud identity options: %w", err)
+ }
+
+ svc, err := cloudidentity.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create cloud identity service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/cloudresourcemanager.go b/internal/googleapi/cloudresourcemanager.go
new file mode 100644
index 00000000..9954b57b
--- /dev/null
+++ b/internal/googleapi/cloudresourcemanager.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/cloudresourcemanager/v3"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewCloudResourceManager(ctx context.Context, email string) (*cloudresourcemanager.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceCloudResource, email)
+ if err != nil {
+ return nil, fmt.Errorf("cloud resource manager options: %w", err)
+ }
+
+ svc, err := cloudresourcemanager.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create cloud resource manager service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/datatransfer.go b/internal/googleapi/datatransfer.go
new file mode 100644
index 00000000..84dd2ed5
--- /dev/null
+++ b/internal/googleapi/datatransfer.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ datatransfer "google.golang.org/api/admin/datatransfer/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewDataTransfer(ctx context.Context, email string) (*datatransfer.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceDataTransfer, email)
+ if err != nil {
+ return nil, fmt.Errorf("datatransfer options: %w", err)
+ }
+
+ svc, err := datatransfer.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create datatransfer service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/drive.go b/internal/googleapi/drive.go
index 5e1bde75..c834b518 100644
--- a/internal/googleapi/drive.go
+++ b/internal/googleapi/drive.go
@@ -3,12 +3,25 @@ package googleapi
import (
"context"
"fmt"
+ "strings"
"google.golang.org/api/drive/v3"
"github.com/steipete/gogcli/internal/googleauth"
)
+// EscapeDriveQueryValue escapes a value for safe interpolation into a
+// Google Drive API query string. At minimum it handles backslashes and
+// single quotes which are the two characters with special meaning inside
+// single-quoted literals in the Drive query grammar.
+func EscapeDriveQueryValue(s string) string {
+ // Escape backslashes first, then single quotes.
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, "'", "\\'")
+
+ return s
+}
+
func NewDrive(ctx context.Context, email string) (*drive.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceDrive, email); err != nil {
return nil, fmt.Errorf("drive options: %w", err)
diff --git a/internal/googleapi/drive_test.go b/internal/googleapi/drive_test.go
new file mode 100644
index 00000000..86b7f637
--- /dev/null
+++ b/internal/googleapi/drive_test.go
@@ -0,0 +1,30 @@
+package googleapi
+
+import "testing"
+
+func TestEscapeDriveQueryValue(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty", "", ""},
+ {"no special chars", "hello", "hello"},
+ {"single quote", "'", "\\'"},
+ {"backslash", "\\", "\\\\"},
+ {"backslash then quote", "\\'", "\\\\\\'"},
+ {"multiple quotes", "a'b'c", "a\\'b\\'c"},
+ {"already escaped", "a\\\\'b", "a\\\\\\\\\\'b"},
+ {"email address", "user@example.com", "user@example.com"},
+ {"folder ID", "1A2B3C_xyz", "1A2B3C_xyz"},
+ {"mixed", "it's a \\path", "it\\'s a \\\\path"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := EscapeDriveQueryValue(tt.in)
+ if got != tt.want {
+ t.Errorf("EscapeDriveQueryValue(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/googleapi/driveactivity.go b/internal/googleapi/driveactivity.go
new file mode 100644
index 00000000..feab641c
--- /dev/null
+++ b/internal/googleapi/driveactivity.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/driveactivity/v2"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewDriveActivity(ctx context.Context, email string) (*driveactivity.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceDriveActivity, email)
+ if err != nil {
+ return nil, fmt.Errorf("drive activity options: %w", err)
+ }
+
+ svc, err := driveactivity.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create drive activity service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/drivelabels.go b/internal/googleapi/drivelabels.go
new file mode 100644
index 00000000..e1acd453
--- /dev/null
+++ b/internal/googleapi/drivelabels.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/drivelabels/v2"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewDriveLabels(ctx context.Context, email string) (*drivelabels.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceDriveLabels, email)
+ if err != nil {
+ return nil, fmt.Errorf("drive labels options: %w", err)
+ }
+
+ svc, err := drivelabels.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create drive labels service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/forms.go b/internal/googleapi/forms.go
new file mode 100644
index 00000000..407b2c38
--- /dev/null
+++ b/internal/googleapi/forms.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/forms/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewForms(ctx context.Context, email string) (*forms.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceForms, email)
+ if err != nil {
+ return nil, fmt.Errorf("forms options: %w", err)
+ }
+
+ svc, err := forms.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create forms service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/groupssettings.go b/internal/googleapi/groupssettings.go
new file mode 100644
index 00000000..0c0cfde1
--- /dev/null
+++ b/internal/googleapi/groupssettings.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/groupssettings/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewGroupsSettings(ctx context.Context, email string) (*groupssettings.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceAdminDirectory, email)
+ if err != nil {
+ return nil, fmt.Errorf("groups settings options: %w", err)
+ }
+
+ svc, err := groupssettings.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create groups settings service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/iam.go b/internal/googleapi/iam.go
new file mode 100644
index 00000000..3083c9b7
--- /dev/null
+++ b/internal/googleapi/iam.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/iam/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewIAM(ctx context.Context, email string) (*iam.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceIAM, email)
+ if err != nil {
+ return nil, fmt.Errorf("iam options: %w", err)
+ }
+
+ svc, err := iam.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create iam service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/licensing.go b/internal/googleapi/licensing.go
new file mode 100644
index 00000000..df49d444
--- /dev/null
+++ b/internal/googleapi/licensing.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/licensing/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewLicensing(ctx context.Context, email string) (*licensing.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceLicensing, email)
+ if err != nil {
+ return nil, fmt.Errorf("licensing options: %w", err)
+ }
+
+ svc, err := licensing.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create licensing service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/meet.go b/internal/googleapi/meet.go
new file mode 100644
index 00000000..be522478
--- /dev/null
+++ b/internal/googleapi/meet.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/meet/v2"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewMeet(ctx context.Context, email string) (*meet.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceMeet, email)
+ if err != nil {
+ return nil, fmt.Errorf("meet options: %w", err)
+ }
+
+ svc, err := meet.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create meet service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/reports.go b/internal/googleapi/reports.go
new file mode 100644
index 00000000..f371104f
--- /dev/null
+++ b/internal/googleapi/reports.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ reports "google.golang.org/api/admin/reports/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewReports(ctx context.Context, email string) (*reports.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceReports, email)
+ if err != nil {
+ return nil, fmt.Errorf("reports options: %w", err)
+ }
+
+ svc, err := reports.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create reports service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/reseller.go b/internal/googleapi/reseller.go
new file mode 100644
index 00000000..a99be885
--- /dev/null
+++ b/internal/googleapi/reseller.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/reseller/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewReseller(ctx context.Context, email string) (*reseller.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceReseller, email)
+ if err != nil {
+ return nil, fmt.Errorf("reseller options: %w", err)
+ }
+
+ svc, err := reseller.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create reseller service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/storage.go b/internal/googleapi/storage.go
new file mode 100644
index 00000000..e764cde2
--- /dev/null
+++ b/internal/googleapi/storage.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/storage/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewStorage(ctx context.Context, email string) (*storage.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceVault, email)
+ if err != nil {
+ return nil, fmt.Errorf("storage options: %w", err)
+ }
+
+ svc, err := storage.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create storage service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/vault.go b/internal/googleapi/vault.go
new file mode 100644
index 00000000..41a519b0
--- /dev/null
+++ b/internal/googleapi/vault.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/vault/v1"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewVault(ctx context.Context, email string) (*vault.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceVault, email)
+ if err != nil {
+ return nil, fmt.Errorf("vault options: %w", err)
+ }
+
+ svc, err := vault.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create vault service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleapi/youtube.go b/internal/googleapi/youtube.go
new file mode 100644
index 00000000..b3e68d63
--- /dev/null
+++ b/internal/googleapi/youtube.go
@@ -0,0 +1,24 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/youtube/v3"
+
+ "github.com/steipete/gogcli/internal/googleauth"
+)
+
+func NewYouTube(ctx context.Context, email string) (*youtube.Service, error) {
+ opts, err := optionsForAccount(ctx, googleauth.ServiceYouTube, email)
+ if err != nil {
+ return nil, fmt.Errorf("youtube options: %w", err)
+ }
+
+ svc, err := youtube.NewService(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create youtube service: %w", err)
+ }
+
+ return svc, nil
+}
diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go
index e59cdb4e..4c32efcb 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -10,18 +10,37 @@ import (
type Service string
const (
- ServiceGmail Service = "gmail"
- ServiceCalendar Service = "calendar"
- ServiceChat Service = "chat"
- ServiceClassroom Service = "classroom"
- ServiceDrive Service = "drive"
- ServiceDocs Service = "docs"
- ServiceContacts Service = "contacts"
- ServiceTasks Service = "tasks"
- ServicePeople Service = "people"
- ServiceSheets Service = "sheets"
- ServiceGroups Service = "groups"
- ServiceKeep Service = "keep"
+ ServiceGmail Service = "gmail"
+ ServiceCalendar Service = "calendar"
+ ServiceChat Service = "chat"
+ ServiceClassroom Service = "classroom"
+ ServiceDrive Service = "drive"
+ ServiceDocs Service = "docs"
+ ServiceContacts Service = "contacts"
+ ServiceTasks Service = "tasks"
+ ServicePeople Service = "people"
+ ServiceSheets Service = "sheets"
+ ServiceGroups Service = "groups"
+ ServiceKeep Service = "keep"
+ ServiceAdminDirectory Service = "admin"
+ ServiceReports Service = "reports"
+ ServiceVault Service = "vault"
+ ServiceAlertCenter Service = "alertcenter"
+ ServiceInboundSSO Service = "inboundsso"
+ ServiceAccessContext Service = "accesscontext"
+ ServiceLicensing Service = "licensing"
+ ServiceDataTransfer Service = "datatransfer"
+ ServiceForms Service = "forms"
+ ServiceYouTube Service = "youtube"
+ ServiceMeet Service = "meet"
+ ServiceAnalytics Service = "analytics"
+ ServiceDriveLabels Service = "drivelabels"
+ ServiceDriveActivity Service = "driveactivity"
+ ServiceCloudIdentity Service = "cloudidentity"
+ ServiceReseller Service = "reseller"
+ ServiceCloudChannel Service = "cloudchannel"
+ ServiceCloudResource Service = "cloudresourcemanager"
+ ServiceIAM Service = "iam"
)
const (
@@ -68,6 +87,25 @@ var serviceOrder = []Service{
ServicePeople,
ServiceGroups,
ServiceKeep,
+ ServiceAdminDirectory,
+ ServiceReports,
+ ServiceVault,
+ ServiceAlertCenter,
+ ServiceInboundSSO,
+ ServiceAccessContext,
+ ServiceLicensing,
+ ServiceDataTransfer,
+ ServiceForms,
+ ServiceYouTube,
+ ServiceMeet,
+ ServiceAnalytics,
+ ServiceDriveLabels,
+ ServiceDriveActivity,
+ ServiceCloudIdentity,
+ ServiceReseller,
+ ServiceCloudChannel,
+ ServiceCloudResource,
+ ServiceIAM,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -170,6 +208,198 @@ var serviceInfoByService = map[Service]serviceInfo{
apis: []string{"Keep API"},
note: "Workspace only; service account (domain-wide delegation)",
},
+ ServiceAdminDirectory: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/admin.directory.user",
+ "https://www.googleapis.com/auth/admin.directory.user.readonly",
+ "https://www.googleapis.com/auth/admin.directory.orgunit",
+ "https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
+ "https://www.googleapis.com/auth/admin.directory.group",
+ "https://www.googleapis.com/auth/admin.directory.group.readonly",
+ "https://www.googleapis.com/auth/admin.directory.group.member",
+ "https://www.googleapis.com/auth/admin.directory.group.member.readonly",
+ "https://www.googleapis.com/auth/admin.directory.domain",
+ "https://www.googleapis.com/auth/admin.directory.domain.readonly",
+ "https://www.googleapis.com/auth/admin.directory.rolemanagement",
+ "https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly",
+ "https://www.googleapis.com/auth/admin.directory.userschema",
+ "https://www.googleapis.com/auth/admin.directory.userschema.readonly",
+ "https://www.googleapis.com/auth/admin.directory.resource.calendar",
+ "https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly",
+ "https://www.googleapis.com/auth/admin.directory.customer",
+ "https://www.googleapis.com/auth/admin.directory.customer.readonly",
+ "https://www.googleapis.com/auth/apps.groups.settings",
+ "https://www.googleapis.com/auth/admin.chrome.printers",
+ "https://www.googleapis.com/auth/admin.chrome.printers.readonly",
+ },
+ user: false,
+ apis: []string{"Admin SDK Directory API", "Groups Settings API"},
+ note: "Workspace admin (domain-wide delegation)",
+ },
+ ServiceReports: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/admin.reports.audit.readonly",
+ "https://www.googleapis.com/auth/admin.reports.usage.readonly",
+ },
+ user: false,
+ apis: []string{"Admin SDK Reports API"},
+ note: "Workspace audit + usage reports",
+ },
+ ServiceVault: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/ediscovery",
+ "https://www.googleapis.com/auth/ediscovery.readonly",
+ "https://www.googleapis.com/auth/devstorage.read_only",
+ },
+ user: false,
+ apis: []string{"Google Vault API", "Cloud Storage API"},
+ note: "Vault exports (Cloud Storage download)",
+ },
+ ServiceAlertCenter: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/apps.alerts",
+ },
+ user: false,
+ apis: []string{"Google Workspace Alert Center API"},
+ note: "Workspace alerts",
+ },
+ ServiceInboundSSO: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/cloud-identity.inboundsso",
+ "https://www.googleapis.com/auth/cloud-identity.inboundsso.readonly",
+ },
+ user: false,
+ apis: []string{"Cloud Identity API"},
+ note: "Inbound SSO profiles + assignments",
+ },
+ ServiceAccessContext: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/cloud-platform",
+ },
+ user: false,
+ apis: []string{"Access Context Manager API"},
+ note: "Context-aware access levels",
+ },
+ ServiceLicensing: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/apps.licensing",
+ },
+ user: false,
+ apis: []string{"Enterprise License Manager API"},
+ note: "Workspace licenses",
+ },
+ ServiceDataTransfer: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/admin.datatransfer",
+ "https://www.googleapis.com/auth/admin.datatransfer.readonly",
+ },
+ user: false,
+ apis: []string{"Admin SDK Data Transfer API"},
+ note: "Workspace data transfers",
+ },
+ ServiceForms: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/forms.body",
+ "https://www.googleapis.com/auth/forms.responses.readonly",
+ },
+ user: true,
+ apis: []string{"Forms API"},
+ note: "Forms and responses",
+ },
+ ServiceYouTube: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/youtube.readonly",
+ },
+ user: false,
+ apis: []string{"YouTube Data API"},
+ note: "YouTube channels",
+ },
+ ServiceMeet: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/meetings.space.created",
+ "https://www.googleapis.com/auth/meetings.space.readonly",
+ "https://www.googleapis.com/auth/meetings.space.settings",
+ },
+ user: false,
+ apis: []string{"Google Meet API"},
+ note: "Meet spaces",
+ },
+ ServiceAnalytics: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/analytics.edit",
+ "https://www.googleapis.com/auth/analytics.readonly",
+ },
+ user: false,
+ apis: []string{"Google Analytics Admin API"},
+ note: "Analytics admin",
+ },
+ ServiceDriveLabels: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/drive.admin.labels",
+ "https://www.googleapis.com/auth/drive.admin.labels.readonly",
+ "https://www.googleapis.com/auth/drive.labels",
+ "https://www.googleapis.com/auth/drive.labels.readonly",
+ },
+ user: false,
+ apis: []string{"Drive Labels API"},
+ note: "Drive classification labels",
+ },
+ ServiceDriveActivity: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/drive.activity",
+ "https://www.googleapis.com/auth/drive.activity.readonly",
+ },
+ user: false,
+ apis: []string{"Drive Activity API"},
+ note: "Drive activity history",
+ },
+ ServiceCloudIdentity: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/cloud-identity.groups",
+ "https://www.googleapis.com/auth/cloud-identity.groups.readonly",
+ "https://www.googleapis.com/auth/cloud-identity.policies",
+ "https://www.googleapis.com/auth/cloud-identity.policies.readonly",
+ },
+ user: false,
+ apis: []string{"Cloud Identity API"},
+ note: "Cloud Identity groups + policies",
+ },
+ ServiceReseller: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/apps.order",
+ "https://www.googleapis.com/auth/apps.order.readonly",
+ },
+ user: false,
+ apis: []string{"Reseller API"},
+ note: "Workspace reseller",
+ },
+ ServiceCloudChannel: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/apps.order",
+ "https://www.googleapis.com/auth/apps.reports.usage.readonly",
+ },
+ user: false,
+ apis: []string{"Cloud Channel API"},
+ note: "Cloud Channel customers + offers",
+ },
+ ServiceCloudResource: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/cloud-platform.read-only",
+ },
+ user: false,
+ apis: []string{"Cloud Resource Manager API"},
+ note: "GCP projects",
+ },
+ ServiceIAM: {
+ scopes: []string{
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/cloud-platform.read-only",
+ },
+ user: false,
+ apis: []string{"IAM API"},
+ note: "Service accounts",
+ },
}
func ParseService(s string) (Service, error) {
@@ -463,6 +693,8 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string,
return Scopes(service)
case ServiceKeep:
return Scopes(service)
+ case ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms, ServiceYouTube, ServiceMeet, ServiceAnalytics, ServiceDriveLabels, ServiceDriveActivity, ServiceCloudIdentity, ServiceReseller, ServiceCloudChannel, ServiceCloudResource, ServiceIAM:
+ return Scopes(service)
default:
return nil, errUnknownService
}
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 1bb65ad0..5fc7288f 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -20,6 +20,25 @@ func TestParseService(t *testing.T) {
{"sheets", ServiceSheets},
{"groups", ServiceGroups},
{"keep", ServiceKeep},
+ {"admin", ServiceAdminDirectory},
+ {"reports", ServiceReports},
+ {"vault", ServiceVault},
+ {"alertcenter", ServiceAlertCenter},
+ {"inboundsso", ServiceInboundSSO},
+ {"accesscontext", ServiceAccessContext},
+ {"licensing", ServiceLicensing},
+ {"datatransfer", ServiceDataTransfer},
+ {"forms", ServiceForms},
+ {"youtube", ServiceYouTube},
+ {"meet", ServiceMeet},
+ {"analytics", ServiceAnalytics},
+ {"drivelabels", ServiceDriveLabels},
+ {"driveactivity", ServiceDriveActivity},
+ {"cloudidentity", ServiceCloudIdentity},
+ {"reseller", ServiceReseller},
+ {"cloudchannel", ServiceCloudChannel},
+ {"cloudresourcemanager", ServiceCloudResource},
+ {"iam", ServiceIAM},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -62,7 +81,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 12 {
+ if len(svcs) != 31 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -71,7 +90,7 @@ func TestAllServices(t *testing.T) {
seen[s] = true
}
- for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep} {
+ for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms, ServiceYouTube, ServiceMeet, ServiceAnalytics, ServiceDriveLabels, ServiceDriveActivity, ServiceCloudIdentity, ServiceReseller, ServiceCloudChannel, ServiceCloudResource, ServiceIAM} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
@@ -80,16 +99,19 @@ func TestAllServices(t *testing.T) {
func TestUserServices(t *testing.T) {
svcs := UserServices()
- if len(svcs) != 10 {
+ if len(svcs) != 11 {
t.Fatalf("unexpected: %v", svcs)
}
seenDocs := false
+ seenForms := false
for _, s := range svcs {
switch s {
case ServiceDocs:
seenDocs = true
+ case ServiceForms:
+ seenForms = true
case ServiceKeep:
t.Fatalf("unexpected keep in user services")
}
@@ -98,10 +120,14 @@ func TestUserServices(t *testing.T) {
if !seenDocs {
t.Fatalf("missing docs in user services")
}
+
+ if !seenForms {
+ t.Fatalf("missing forms in user services")
+ }
}
func TestUserServiceCSV(t *testing.T) {
- want := "gmail,calendar,chat,classroom,drive,docs,contacts,tasks,sheets,people"
+ want := "gmail,calendar,chat,classroom,drive,docs,contacts,tasks,sheets,people,forms"
if got := UserServiceCSV(); got != want {
t.Fatalf("unexpected user services csv: %q", got)
}
diff --git a/internal/todrive/writer.go b/internal/todrive/writer.go
new file mode 100644
index 00000000..b83adf32
--- /dev/null
+++ b/internal/todrive/writer.go
@@ -0,0 +1,214 @@
+package todrive
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "google.golang.org/api/drive/v3"
+ "google.golang.org/api/sheets/v4"
+
+ "github.com/steipete/gogcli/internal/googleapi"
+)
+
+var (
+ newDriveService = googleapi.NewDrive
+ newSheetsService = googleapi.NewSheets
+)
+
+var errMissingSpreadsheetID = errors.New("missing spreadsheet id")
+
+const defaultSheetName = "Report"
+
+// Options control Google Sheets output.
+type Options struct {
+ SheetName string
+ FolderID string
+ Timestamp bool
+ Notify string
+ Update bool
+}
+
+// Result describes the created or updated spreadsheet.
+type Result struct {
+ SpreadsheetID string
+ SheetName string
+ URL string
+}
+
+type Writer struct {
+ drive *drive.Service
+ sheets *sheets.Service
+}
+
+func New(ctx context.Context, account string) (*Writer, error) {
+ driveSvc, err := newDriveService(ctx, account)
+ if err != nil {
+ return nil, err
+ }
+
+ sheetsSvc, err := newSheetsService(ctx, account)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Writer{drive: driveSvc, sheets: sheetsSvc}, nil
+}
+
+func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, opts Options) (*Result, error) {
+ title := strings.TrimSpace(opts.SheetName)
+ if title == "" {
+ title = defaultSheetName
+ }
+
+ if opts.Timestamp {
+ title = fmt.Sprintf("%s-%s", title, time.Now().Format("2006-01-02-150405"))
+ }
+
+ spreadsheetID := ""
+ spreadsheetURL := ""
+
+ if opts.Update {
+ id, url, err := w.findSpreadsheet(ctx, title, opts.FolderID)
+ if err != nil {
+ return nil, err
+ }
+ spreadsheetID = id
+ spreadsheetURL = url
+ }
+
+ sheetName := "Data"
+ if spreadsheetID == "" {
+ created, err := w.sheets.Spreadsheets.Create(&sheets.Spreadsheet{
+ Properties: &sheets.SpreadsheetProperties{Title: title},
+ Sheets: []*sheets.Sheet{{
+ Properties: &sheets.SheetProperties{Title: sheetName},
+ }},
+ }).Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("create sheet: %w", err)
+ }
+ spreadsheetID = created.SpreadsheetId
+ spreadsheetURL = created.SpreadsheetUrl
+
+ if strings.TrimSpace(opts.FolderID) != "" {
+ if err := w.moveToFolder(ctx, spreadsheetID, opts.FolderID); err != nil {
+ return nil, err
+ }
+ }
+ } else {
+ // Get the first sheet name from existing spreadsheet metadata
+ ss, err := w.sheets.Spreadsheets.Get(spreadsheetID).Fields("sheets.properties.title").Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("fetch spreadsheet metadata: %w", err)
+ }
+
+ if len(ss.Sheets) > 0 && ss.Sheets[0].Properties != nil && ss.Sheets[0].Properties.Title != "" {
+ sheetName = ss.Sheets[0].Properties.Title
+ }
+ }
+
+ if spreadsheetID == "" {
+ return nil, errMissingSpreadsheetID
+ }
+
+ if opts.Update {
+ _, _ = w.sheets.Spreadsheets.Values.Clear(spreadsheetID, sheetName, &sheets.ClearValuesRequest{}).Context(ctx).Do()
+ }
+
+ values := make([][]interface{}, 0, len(rows)+1)
+ if len(headers) > 0 {
+ values = append(values, toInterfaceRow(headers))
+ }
+
+ for _, row := range rows {
+ values = append(values, toInterfaceRow(row))
+ }
+
+ _, err := w.sheets.Spreadsheets.Values.Update(spreadsheetID, sheetName+"!A1", &sheets.ValueRange{
+ Values: values,
+ }).ValueInputOption("RAW").Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("update sheet: %w", err)
+ }
+
+ if strings.TrimSpace(opts.Notify) != "" {
+ if err := w.shareWith(ctx, spreadsheetID, strings.TrimSpace(opts.Notify)); err != nil {
+ return nil, err
+ }
+ }
+
+ if spreadsheetURL == "" {
+ spreadsheetURL = fmt.Sprintf("https://docs.google.com/spreadsheets/d/%s", spreadsheetID)
+ }
+
+ return &Result{
+ SpreadsheetID: spreadsheetID,
+ SheetName: title,
+ URL: spreadsheetURL,
+ }, nil
+}
+
+func (w *Writer) findSpreadsheet(ctx context.Context, name, folderID string) (string, string, error) {
+ query := fmt.Sprintf("mimeType='application/vnd.google-apps.spreadsheet' and name='%s' and trashed=false", googleapi.EscapeDriveQueryValue(name))
+ if strings.TrimSpace(folderID) != "" {
+ query = fmt.Sprintf("%s and '%s' in parents", query, googleapi.EscapeDriveQueryValue(strings.TrimSpace(folderID)))
+ }
+
+ resp, err := w.drive.Files.List().Q(query).Fields("files(id,name,webViewLink)").Context(ctx).Do()
+ if err != nil {
+ return "", "", fmt.Errorf("find sheet: %w", err)
+ }
+
+ if len(resp.Files) == 0 {
+ return "", "", nil
+ }
+
+ file := resp.Files[0]
+
+ return file.Id, file.WebViewLink, nil
+}
+
+func (w *Writer) moveToFolder(ctx context.Context, fileID, folderID string) error {
+ file, err := w.drive.Files.Get(fileID).Fields("parents").Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("fetch parents: %w", err)
+ }
+
+ remove := strings.Join(file.Parents, ",")
+
+ call := w.drive.Files.Update(fileID, nil).AddParents(folderID)
+ if remove != "" {
+ call = call.RemoveParents(remove)
+ }
+
+ if _, err := call.Context(ctx).Do(); err != nil {
+ return fmt.Errorf("move sheet: %w", err)
+ }
+
+ return nil
+}
+
+func (w *Writer) shareWith(ctx context.Context, fileID, email string) error {
+ _, err := w.drive.Permissions.Create(fileID, &drive.Permission{
+ Type: "user",
+ Role: "reader",
+ EmailAddress: email,
+ }).SendNotificationEmail(true).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("share sheet: %w", err)
+ }
+
+ return nil
+}
+
+func toInterfaceRow(values []string) []interface{} {
+ row := make([]interface{}, len(values))
+ for i, value := range values {
+ row[i] = value
+ }
+
+ return row
+}
diff --git a/internal/todrive/writer_test.go b/internal/todrive/writer_test.go
new file mode 100644
index 00000000..80e29a09
--- /dev/null
+++ b/internal/todrive/writer_test.go
@@ -0,0 +1,90 @@
+package todrive
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/drive/v3"
+ "google.golang.org/api/option"
+ "google.golang.org/api/sheets/v4"
+)
+
+func TestWriterCreateAndWrite(t *testing.T) {
+ var gotValues [][]interface{}
+
+ sheetsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v4/spreadsheets":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "spreadsheetId": "sheet1",
+ "spreadsheetUrl": "https://sheet/1",
+ })
+
+ return
+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/v4/spreadsheets/sheet1/values/Data!A1"):
+ var vr sheets.ValueRange
+ _ = json.NewDecoder(r.Body).Decode(&vr)
+ gotValues = vr.Values
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"updatedRange": "Data!A1"})
+
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer sheetsSrv.Close()
+
+ driveSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer driveSrv.Close()
+
+ origSheets := newSheetsService
+ origDrive := newDriveService
+
+ t.Cleanup(func() {
+ newSheetsService = origSheets
+ newDriveService = origDrive
+ })
+
+ newSheetsService = func(ctx context.Context, _ string) (*sheets.Service, error) {
+ return sheets.NewService(ctx,
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(sheetsSrv.Client()),
+ option.WithEndpoint(sheetsSrv.URL+"/"),
+ )
+ }
+ newDriveService = func(ctx context.Context, _ string) (*drive.Service, error) {
+ return drive.NewService(ctx,
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(driveSrv.Client()),
+ option.WithEndpoint(driveSrv.URL+"/"),
+ )
+ }
+
+ writer, err := New(context.Background(), "user@example.com")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ res, err := writer.Write(context.Background(), []string{"A", "B"}, [][]string{{"1", "2"}}, Options{SheetName: "Report"})
+ if err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+
+ if res.SpreadsheetID != "sheet1" {
+ t.Fatalf("unexpected id: %s", res.SpreadsheetID)
+ }
+
+ if len(gotValues) != 2 {
+ t.Fatalf("expected 2 rows, got %d", len(gotValues))
+ }
+}