From 429afc746fef45d628be166ecca79e27d0bc6f01 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Sat, 20 Jun 2026 09:27:08 -0700 Subject: [PATCH] Add share-link list command and restructure as top-level command Move share-link from `share link` subcommand to top-level `share-link` command. Add `share-link list [path]` with optional path scoping using DirectOnly=true. Deprecate old `share list link` (still functional). Add verbose status output helpers (commandVerbose, commandVerboseStatus) for share-link create/list. Refactor link rendering to use io.Writer instead of global stdout. --- README.md | 12 +- cmd/output.go | 22 ++++ cmd/output_test.go | 36 ++++++ cmd/share-list-links.go | 108 +++++++++++++---- cmd/share_create_link_test.go | 216 +++++++++++++++++++++++++++++++++- cmd/share_link.go | 6 +- cmd/share_link_create.go | 13 +- cmd/share_link_info.go | 123 +++++++++++++++++++ cmd/share_link_info_test.go | 196 ++++++++++++++++++++++++++++++ cmd/share_link_revoke.go | 52 ++++++++ cmd/share_link_revoke_test.go | 150 +++++++++++++++++++++++ cmd/share_link_update.go | 9 +- cmd/share_link_update_test.go | 6 +- 13 files changed, 903 insertions(+), 46 deletions(-) create mode 100644 cmd/share_link_info.go create mode 100644 cmd/share_link_info_test.go create mode 100644 cmd/share_link_revoke.go create mode 100644 cmd/share_link_revoke_test.go diff --git a/README.md b/README.md index 69fa561..333280b 100644 --- a/README.md +++ b/README.md @@ -228,12 +228,18 @@ All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `l ### Sharing ```sh -$ dbxcli share link create /file.txt # create or return an existing shared link -$ dbxcli share link update --allow-download # update shared link settings -$ dbxcli share list link # list existing shared links +$ dbxcli share-link create /file.txt # create or return an existing shared link +$ dbxcli share-link info # display shared link information +$ dbxcli share-link list # list existing shared links +$ dbxcli share-link list /file.txt # list direct shared links for a path +$ dbxcli share-link revoke # revoke a shared link +$ dbxcli share-link update --allow-download # update shared link settings +$ dbxcli share list link # deprecated compatibility command $ dbxcli share list folder # list shared folders ``` +New and changed commands should write command results to stdout. Status, progress, warnings, diagnostics, and verbose logs should go to stderr. + ### Team management ```sh diff --git a/cmd/output.go b/cmd/output.go index f60c366..1fd1128 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -28,3 +28,25 @@ func commandOutputFormat(cmd *cobra.Command) output.Format { } return output.FormatText } + +func commandVerbose(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + verbose, err := cmd.Flags().GetBool("verbose") + if err == nil { + return verbose + } + verbose, err = cmd.InheritedFlags().GetBool("verbose") + if err == nil { + return verbose + } + verbose, err = cmd.PersistentFlags().GetBool("verbose") + return err == nil && verbose +} + +func commandVerboseStatus(cmd *cobra.Command, format string, args ...any) { + if commandVerbose(cmd) { + commandOutput(cmd).Status(format, args...) + } +} diff --git a/cmd/output_test.go b/cmd/output_test.go index f0bfa7a..bb20cdd 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -73,3 +73,39 @@ func TestCommandOutputHonorsInheritedJSONFlag(t *testing.T) { t.Fatalf("stdout = %q, want %q", got, want) } } + +func TestCommandVerboseHonorsInheritedVerboseFlag(t *testing.T) { + root := &cobra.Command{} + root.PersistentFlags().BoolP("verbose", "v", false, "") + + cmd := &cobra.Command{} + root.AddCommand(cmd) + + if err := root.PersistentFlags().Set("verbose", "true"); err != nil { + t.Fatalf("set verbose: %v", err) + } + + if !commandVerbose(cmd) { + t.Fatal("commandVerbose = false, want true") + } +} + +func TestCommandVerboseStatusWritesOnlyWhenVerbose(t *testing.T) { + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", false, "") + cmd.SetErr(&stderr) + + commandVerboseStatus(cmd, "done %s", "quietly") + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + + if err := cmd.Flags().Set("verbose", "true"); err != nil { + t.Fatalf("set verbose: %v", err) + } + commandVerboseStatus(cmd, "done %s", "loudly") + if got, want := stderr.String(), "done loudly\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} diff --git a/cmd/share-list-links.go b/cmd/share-list-links.go index d784b2e..2bedd83 100644 --- a/cmd/share-list-links.go +++ b/cmd/share-list-links.go @@ -15,61 +15,121 @@ package cmd import ( + "errors" "fmt" + "io" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" ) func shareListLinks(cmd *cobra.Command, args []string) (err error) { + return shareLinkList(cmd, args) +} + +func shareLinkList(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return errors.New("`share-link list` accepts at most one `path` argument") + } + arg := sharing.NewListSharedLinksArg() + if len(args) == 1 { + path, err := validatePath(args[0]) + if err != nil { + return err + } + arg.Path = path + arg.DirectOnly = true + } - dbx := sharing.New(config) - res, err := dbx.ListSharedLinks(arg) + dbx := newSharedLinkClient(config) + links, err := listSharedLinks(dbx, arg) if err != nil { - return + return err } - printLinks(res.Links) + if arg.Path != "" { + commandVerboseStatus(cmd, "Listed %d shared links for %s", len(links), arg.Path) + } else { + commandVerboseStatus(cmd, "Listed %d shared links", len(links)) + } - for res.HasMore { - arg = sharing.NewListSharedLinksArg() - arg.Cursor = res.Cursor + return commandOutput(cmd).RenderText(func(w io.Writer) error { + return renderSharedLinks(w, links) + }) +} - res, err = dbx.ListSharedLinks(arg) +func listSharedLinks(dbx sharedLinkClient, arg *sharing.ListSharedLinksArg) ([]sharing.IsSharedLinkMetadata, error) { + var links []sharing.IsSharedLinkMetadata + for { + res, err := dbx.ListSharedLinks(arg) if err != nil { - return + return nil, err } + links = append(links, res.Links...) - printLinks(res.Links) + if !res.HasMore { + break + } + if res.Cursor == "" { + return nil, errors.New("shared link list has more results but no cursor") + } + arg = sharing.NewListSharedLinksArg() + arg.Cursor = res.Cursor } - return + return links, nil } -func printLinks(links []sharing.IsSharedLinkMetadata) { +func renderSharedLinks(out io.Writer, links []sharing.IsSharedLinkMetadata) error { for _, l := range links { - switch sl := l.(type) { - case *sharing.FileLinkMetadata: - printLink(sl.SharedLinkMetadata) - case *sharing.FolderLinkMetadata: - printLink(sl.SharedLinkMetadata) - default: - fmt.Printf("found unknown shared link type") + name, url, ok := sharedLinkDisplay(l) + if !ok { + return errors.New("found unknown shared link type") + } + if _, err := fmt.Fprintf(out, "%s\t%s\n", name, url); err != nil { + return err } } + + return nil } -func printLink(sl sharing.SharedLinkMetadata) { - fmt.Printf("%v\t%v\n", sl.Name, sl.Url) +func sharedLinkDisplay(link sharing.IsSharedLinkMetadata) (name string, url string, ok bool) { + switch sl := link.(type) { + case *sharing.FileLinkMetadata: + return sharedLinkMetadataDisplay(sl.SharedLinkMetadata) + case *sharing.FolderLinkMetadata: + return sharedLinkMetadataDisplay(sl.SharedLinkMetadata) + case *sharing.SharedLinkMetadata: + return sharedLinkMetadataDisplay(*sl) + default: + return "", "", false + } } -var shareListLinksCmd = &cobra.Command{ - Use: "link", +func sharedLinkMetadataDisplay(sl sharing.SharedLinkMetadata) (name string, url string, ok bool) { + name = sl.Name + if name == "" { + name = sl.PathLower + } + return name, sl.Url, sl.Url != "" +} + +var shareLinkListCmd = &cobra.Command{ + Use: "list [path]", Short: "List shared links", - RunE: shareListLinks, + RunE: shareLinkList, +} + +var shareListLinksCmd = &cobra.Command{ + Use: "link", + Short: "List shared links", + Deprecated: "use `dbxcli share-link list` instead", + RunE: shareListLinks, } func init() { + shareLinkCmd.AddCommand(shareLinkListCmd) shareListCmd.AddCommand(shareListLinksCmd) } diff --git a/cmd/share_create_link_test.go b/cmd/share_create_link_test.go index dfc73df..47a7c26 100644 --- a/cmd/share_create_link_test.go +++ b/cmd/share_create_link_test.go @@ -17,6 +17,7 @@ package cmd import ( "bytes" "fmt" + "path" "strings" "testing" "time" @@ -28,8 +29,10 @@ import ( type mockSharedLinkClient struct { createSharedLinkWithSettingsFn func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) + getSharedLinkMetadataFn func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) listSharedLinksFn func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) modifySharedLinkSettingsFn func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) + revokeSharedLinkFn func(arg *sharing.RevokeSharedLinkArg) error } func (m *mockSharedLinkClient) CreateSharedLinkWithSettings(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { @@ -39,6 +42,13 @@ func (m *mockSharedLinkClient) CreateSharedLinkWithSettings(arg *sharing.CreateS return nil, nil } +func (m *mockSharedLinkClient) GetSharedLinkMetadata(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if m.getSharedLinkMetadataFn != nil { + return m.getSharedLinkMetadataFn(arg) + } + return nil, nil +} + func (m *mockSharedLinkClient) ListSharedLinks(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { if m.listSharedLinksFn != nil { return m.listSharedLinksFn(arg) @@ -53,6 +63,13 @@ func (m *mockSharedLinkClient) ModifySharedLinkSettings(arg *sharing.ModifyShare return nil, nil } +func (m *mockSharedLinkClient) RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error { + if m.revokeSharedLinkFn != nil { + return m.revokeSharedLinkFn(arg) + } + return nil +} + func stubSharedLinkClient(t *testing.T, client sharedLinkClient) { t.Helper() @@ -81,7 +98,7 @@ func TestSharedLinkCreateRequiresExactlyOnePath(t *testing.T) { }) err := shareLinkCreate(&cobra.Command{}, tt.args) - if err == nil || !strings.Contains(err.Error(), "requires a `path` argument") { + if err == nil || !strings.Contains(err.Error(), "`share-link create` requires a `path` argument") { t.Fatalf("error = %v, want path argument error", err) } if called { @@ -145,9 +162,11 @@ func TestSharedLinkCreateVerboseStillPrintsURLOnly(t *testing.T) { stubSharedLinkClient(t, mock) var stdout bytes.Buffer + var stderr bytes.Buffer cmd := &cobra.Command{} cmd.Flags().Bool("verbose", true, "") cmd.SetOut(&stdout) + cmd.SetErr(&stderr) if err := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil { t.Fatalf("shareLinkCreate error: %v", err) @@ -156,6 +175,9 @@ func TestSharedLinkCreateVerboseStillPrintsURLOnly(t *testing.T) { if got := stdout.String(); got != "https://example.com/file\n" { t.Fatalf("stdout = %q, want URL only", got) } + if got, want := stderr.String(), "Created shared link for /file.txt\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } } func TestSharedLinkCreatePrintsFolderURL(t *testing.T) { @@ -224,6 +246,34 @@ func TestSharedLinkCreateExistingMetadataPrintsURLWithoutList(t *testing.T) { } } +func TestSharedLinkCreateVerboseReportsExistingLinkOnStderr(t *testing.T) { + existing := sharedLinkFolder("/docs", "https://example.com/docs") + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + return nil, alreadyExistsError(existing) + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", true, "") + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := shareLinkCreate(cmd, []string{"/docs"}); err != nil { + t.Fatalf("shareLinkCreate error: %v", err) + } + + if got := stdout.String(); got != "https://example.com/docs\n" { + t.Fatalf("stdout = %q, want existing URL only", got) + } + if got, want := stderr.String(), "Using existing shared link for /docs\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + func TestSharedLinkCreateFallbackPrefersExactPathLower(t *testing.T) { var listArg *sharing.ListSharedLinksArg mock := &mockSharedLinkClient{ @@ -336,12 +386,12 @@ func TestSharedLinkCreateFallbackPaginationRequiresCursor(t *testing.T) { } func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { - cmd, _, err := RootCmd.Find([]string{"share", "link", "create", "/file.txt"}) + cmd, _, err := RootCmd.Find([]string{"share-link", "create", "/file.txt"}) if err != nil { - t.Fatalf("find share link create: %v", err) + t.Fatalf("find share-link create: %v", err) } if cmd != shareLinkCreateCmd { - t.Fatalf("share link create resolved to %q", cmd.CommandPath()) + t.Fatalf("share-link create resolved to %q", cmd.CommandPath()) } cmd, _, err = RootCmd.Find([]string{"share", "list", "link"}) @@ -351,16 +401,170 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { if cmd != shareListLinksCmd { t.Fatalf("share list link resolved to %q", cmd.CommandPath()) } + if shareListLinksCmd.Deprecated == "" { + t.Fatal("share list link should be deprecated") + } + if !strings.Contains(shareListLinksCmd.Deprecated, "share-link list") { + t.Fatalf("deprecation message = %q, want share-link list replacement", shareListLinksCmd.Deprecated) + } +} + +func TestShareLinkListListsAllLinks(t *testing.T) { + var listArg *sharing.ListSharedLinksArg + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + listArg = arg + return sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/file.txt", "https://example.com/file"), + sharedLinkFolder("/docs", "https://example.com/docs"), + }, false), nil + }, + }) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + + if err := shareLinkList(cmd, nil); err != nil { + t.Fatalf("shareLinkList error: %v", err) + } + + if listArg == nil { + t.Fatal("expected ListSharedLinks to be called") + } + if listArg.Path != "" { + t.Fatalf("ListSharedLinks path = %q, want empty", listArg.Path) + } + if listArg.DirectOnly { + t.Fatal("ListSharedLinks DirectOnly = true, want false") + } + want := "file.txt\thttps://example.com/file\n" + + "docs\thttps://example.com/docs\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestShareLinkListVerboseWritesStatusToStderr(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + return sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/file.txt", "https://example.com/file"), + }, false), nil + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", true, "") + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := shareLinkList(cmd, []string{"/docs/file.txt"}); err != nil { + t.Fatalf("shareLinkList error: %v", err) + } + + if got, want := stdout.String(), "file.txt\thttps://example.com/file\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } + if got, want := stderr.String(), "Listed 1 shared links for /docs/file.txt\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestShareLinkListPathFilterUsesDirectOnly(t *testing.T) { + var listArg *sharing.ListSharedLinksArg + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + listArg = arg + return sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/file.txt", "https://example.com/file"), + }, false), nil + }, + }) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + + if err := shareLinkList(cmd, []string{"docs/file.txt"}); err != nil { + t.Fatalf("shareLinkList error: %v", err) + } + + if listArg == nil { + t.Fatal("expected ListSharedLinks to be called") + } + if listArg.Path != "/docs/file.txt" { + t.Fatalf("ListSharedLinks path = %q, want /docs/file.txt", listArg.Path) + } + if !listArg.DirectOnly { + t.Fatal("ListSharedLinks DirectOnly = false, want true") + } + want := "file.txt\thttps://example.com/file\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestShareLinkListFollowsPagination(t *testing.T) { + var cursors []string + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + cursors = append(cursors, arg.Cursor) + if arg.Cursor == "" { + res := sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/one.txt", "https://example.com/one"), + }, true) + res.Cursor = "next-page" + return res, nil + } + return sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/two.txt", "https://example.com/two"), + }, false), nil + }, + }) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + + if err := shareLinkList(cmd, nil); err != nil { + t.Fatalf("shareLinkList error: %v", err) + } + + if got := strings.Join(cursors, ","); got != ",next-page" { + t.Fatalf("cursors = %q, want first call then next-page", got) + } + got := stdout.String() + for _, want := range []string{"https://example.com/one", "https://example.com/two"} { + if !strings.Contains(got, want) { + t.Fatalf("stdout = %q, missing %q", got, want) + } + } +} + +func TestShareLinkListPaginationRequiresCursor(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + return sharing.NewListSharedLinksResult(nil, true), nil + }, + }) + + err := shareLinkList(&cobra.Command{}, nil) + if err == nil || !strings.Contains(err.Error(), "more results but no cursor") { + t.Fatalf("error = %v, want missing cursor error", err) + } } func sharedLinkFile(pathLower string, url string) *sharing.FileLinkMetadata { - link := sharing.NewFileLinkMetadata(url, strings.TrimPrefix(pathLower, "/"), nil, time.Time{}, time.Time{}, "rev", 1) + link := sharing.NewFileLinkMetadata(url, path.Base(pathLower), nil, time.Time{}, time.Time{}, "rev", 1) link.PathLower = strings.ToLower(pathLower) return link } func sharedLinkFolder(pathLower string, url string) *sharing.FolderLinkMetadata { - link := sharing.NewFolderLinkMetadata(url, strings.TrimPrefix(pathLower, "/"), nil) + link := sharing.NewFolderLinkMetadata(url, path.Base(pathLower), nil) link.PathLower = strings.ToLower(pathLower) return link } diff --git a/cmd/share_link.go b/cmd/share_link.go index 472fd48..6ce220f 100644 --- a/cmd/share_link.go +++ b/cmd/share_link.go @@ -22,8 +22,10 @@ import ( type sharedLinkClient interface { CreateSharedLinkWithSettings(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) + GetSharedLinkMetadata(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) ListSharedLinks(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) ModifySharedLinkSettings(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) + RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error } var newSharedLinkClient = func(cfg dropbox.Config) sharedLinkClient { @@ -31,10 +33,10 @@ var newSharedLinkClient = func(cfg dropbox.Config) sharedLinkClient { } var shareLinkCmd = &cobra.Command{ - Use: "link", + Use: "share-link", Short: "Shared link commands", } func init() { - shareCmd.AddCommand(shareLinkCmd) + RootCmd.AddCommand(shareLinkCmd) } diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 6f067ba..8c939dc 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -26,7 +26,7 @@ import ( func shareLinkCreate(cmd *cobra.Command, args []string) error { if len(args) != 1 { - return errors.New("`share link create` requires a `path` argument") + return errors.New("`share-link create` requires a `path` argument") } path, err := validatePath(args[0]) @@ -40,11 +40,13 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { dbx := newSharedLinkClient(config) arg := sharing.NewCreateSharedLinkWithSettingsArg(path) link, err := dbx.CreateSharedLinkWithSettings(arg) + usedExisting := false if err != nil { link, err = existingSharedLink(dbx, path, err) if err != nil { return err } + usedExisting = true } url, ok := sharedLinkURL(link) @@ -52,7 +54,14 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { return errors.New("shared link response did not include a URL") } - return commandOutput(cmd).RenderText(func(w io.Writer) error { + out := commandOutput(cmd) + if usedExisting { + commandVerboseStatus(cmd, "Using existing shared link for %s", path) + } else { + commandVerboseStatus(cmd, "Created shared link for %s", path) + } + + return out.RenderText(func(w io.Writer) error { _, err := fmt.Fprintln(w, url) return err }) diff --git a/cmd/share_link_info.go b/cmd/share_link_info.go new file mode 100644 index 0000000..0051d5f --- /dev/null +++ b/cmd/share_link_info.go @@ -0,0 +1,123 @@ +// Copyright © 2016 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "errors" + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" + "github.com/dustin/go-humanize" + "github.com/spf13/cobra" +) + +func shareLinkInfo(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("`share-link info` requires a `url` argument") + } + + url := args[0] + if url == "" { + return errors.New("`share-link info` requires a non-empty URL") + } + + dbx := newSharedLinkClient(config) + arg := sharing.NewGetSharedLinkMetadataArg(url) + link, err := dbx.GetSharedLinkMetadata(arg) + if err != nil { + return err + } + + return commandOutput(cmd).RenderText(func(w io.Writer) error { + return renderSharedLinkInfo(w, link) + }) +} + +func renderSharedLinkInfo(out io.Writer, link sharing.IsSharedLinkMetadata) error { + metadata, linkType, ok := sharedLinkBaseMetadata(link) + if !ok { + return errors.New("found unknown shared link type") + } + + w := new(tabwriter.Writer) + w.Init(out, 4, 8, 1, ' ', 0) + + _, _ = fmt.Fprintf(w, "Type:\t%s\n", linkType) + _, _ = fmt.Fprintf(w, "Name:\t%s\n", metadata.Name) + _, _ = fmt.Fprintf(w, "URL:\t%s\n", metadata.Url) + if metadata.PathLower != "" { + _, _ = fmt.Fprintf(w, "Path:\t%s\n", metadata.PathLower) + } + if metadata.Id != "" { + _, _ = fmt.Fprintf(w, "ID:\t%s\n", metadata.Id) + } + if metadata.Expires != nil { + _, _ = fmt.Fprintf(w, "Expires:\t%s\n", metadata.Expires.Format(time.RFC3339)) + } + if metadata.LinkPermissions != nil { + renderSharedLinkPermissions(w, metadata.LinkPermissions) + } + + if file, ok := link.(*sharing.FileLinkMetadata); ok { + _, _ = fmt.Fprintf(w, "Revision:\t%s\n", file.Rev) + _, _ = fmt.Fprintf(w, "Size:\t%s\n", humanize.IBytes(file.Size)) + _, _ = fmt.Fprintf(w, "Server Modified:\t%s\n", file.ServerModified.Format(time.RFC3339)) + } + + return w.Flush() +} + +func renderSharedLinkPermissions(w io.Writer, permissions *sharing.LinkPermissions) { + if permissions.ResolvedVisibility != nil { + _, _ = fmt.Fprintf(w, "Resolved Visibility:\t%s\n", permissions.ResolvedVisibility.Tag) + } + if permissions.RequestedVisibility != nil { + _, _ = fmt.Fprintf(w, "Requested Visibility:\t%s\n", permissions.RequestedVisibility.Tag) + } + if permissions.EffectiveAudience != nil { + _, _ = fmt.Fprintf(w, "Effective Audience:\t%s\n", permissions.EffectiveAudience.Tag) + } + if permissions.LinkAccessLevel != nil { + _, _ = fmt.Fprintf(w, "Access Level:\t%s\n", permissions.LinkAccessLevel.Tag) + } + _, _ = fmt.Fprintf(w, "Can Revoke:\t%t\n", permissions.CanRevoke) + _, _ = fmt.Fprintf(w, "Allow Download:\t%t\n", permissions.AllowDownload) +} + +func sharedLinkBaseMetadata(link sharing.IsSharedLinkMetadata) (*sharing.SharedLinkMetadata, string, bool) { + switch link := link.(type) { + case *sharing.FileLinkMetadata: + return &link.SharedLinkMetadata, "file", true + case *sharing.FolderLinkMetadata: + return &link.SharedLinkMetadata, "folder", true + case *sharing.SharedLinkMetadata: + return link, "link", true + default: + return nil, "", false + } +} + +var shareLinkInfoCmd = &cobra.Command{ + Use: "info ", + Short: "Display shared link information", + RunE: shareLinkInfo, +} + +func init() { + shareLinkCmd.AddCommand(shareLinkInfoCmd) +} diff --git a/cmd/share_link_info_test.go b/cmd/share_link_info_test.go new file mode 100644 index 0000000..1e7c807 --- /dev/null +++ b/cmd/share_link_info_test.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" + "github.com/spf13/cobra" +) + +func TestShareLinkInfoRequiresExactlyOneURL(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "missing url", args: nil}, + {name: "too many urls", args: []string{"http://a", "http://b"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + }) + + err := shareLinkInfo(&cobra.Command{}, tt.args) + if err == nil || !strings.Contains(err.Error(), "requires a `url` argument") { + t.Fatalf("error = %v, want url argument error", err) + } + if called { + t.Fatal("GetSharedLinkMetadata should not be called") + } + }) + } +} + +func TestShareLinkInfoRejectsEmptyURL(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + }) + + err := shareLinkInfo(&cobra.Command{}, []string{""}) + if err == nil || !strings.Contains(err.Error(), "non-empty URL") { + t.Fatalf("error = %v, want non-empty URL error", err) + } + if called { + t.Fatal("GetSharedLinkMetadata should not be called") + } +} + +func TestShareLinkInfoCallsAPIWithURLAndPrintsFileInfo(t *testing.T) { + serverModified := time.Date(2026, 6, 20, 12, 30, 0, 0, time.UTC) + expires := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC) + permissions := sharing.NewLinkPermissions(true, nil, false, false, true, false, false, false, false) + permissions.ResolvedVisibility = &sharing.ResolvedVisibility{Tagged: dropbox.Tagged{Tag: sharing.ResolvedVisibilityPublic}} + + link := sharedLinkFile("/docs/report.txt", "https://www.dropbox.com/s/abc123") + link.Id = "id:file123" + link.Expires = &expires + link.LinkPermissions = permissions + link.ServerModified = serverModified + link.Rev = "rev123" + link.Size = 2048 + + var requestedURL string + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + requestedURL = arg.Url + return link, nil + }, + }) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + + err := shareLinkInfo(cmd, []string{"https://www.dropbox.com/s/abc123"}) + if err != nil { + t.Fatalf("shareLinkInfo error: %v", err) + } + if requestedURL != "https://www.dropbox.com/s/abc123" { + t.Fatalf("requested URL = %q, want https://www.dropbox.com/s/abc123", requestedURL) + } + + got := stdout.String() + for _, want := range []string{ + "Type: file\n", + "Name: report.txt\n", + "URL: https://www.dropbox.com/s/abc123\n", + "Path: /docs/report.txt\n", + "ID: id:file123\n", + "Expires: 2026-07-01T08:00:00Z\n", + "Resolved Visibility: public\n", + "Can Revoke: true\n", + "Allow Download: true\n", + "Revision: rev123\n", + "Size: 2.0 KiB\n", + "Server Modified: 2026-06-20T12:30:00Z\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("stdout = %q, missing %q", got, want) + } + } +} + +func TestShareLinkInfoPrintsFolderInfo(t *testing.T) { + link := sharedLinkFolder("/docs", "https://www.dropbox.com/s/folder") + link.Id = "id:folder123" + + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return link, nil + }, + }) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + + if err := shareLinkInfo(cmd, []string{"https://www.dropbox.com/s/folder"}); err != nil { + t.Fatalf("shareLinkInfo error: %v", err) + } + + got := stdout.String() + for _, want := range []string{ + "Type: folder\n", + "Name: docs\n", + "URL: https://www.dropbox.com/s/folder\n", + "Path: /docs\n", + "ID: id:folder123\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("stdout = %q, missing %q", got, want) + } + } +} + +func TestShareLinkInfoReturnsAPIError(t *testing.T) { + wantErr := fmt.Errorf("shared_link_not_found") + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return nil, wantErr + }, + }) + + err := shareLinkInfo(&cobra.Command{}, []string{"https://www.dropbox.com/s/abc123"}) + if err != wantErr { + t.Fatalf("error = %v, want API error", err) + } +} + +func TestShareLinkInfoDoesNotBreakOtherCommands(t *testing.T) { + cmd, _, err := RootCmd.Find([]string{"share-link", "info"}) + if err != nil { + t.Fatalf("find share-link info: %v", err) + } + if cmd != shareLinkInfoCmd { + t.Fatalf("share-link info resolved to %q", cmd.CommandPath()) + } + + cmd, _, err = RootCmd.Find([]string{"share-link", "create"}) + if err != nil { + t.Fatalf("find share-link create: %v", err) + } + if cmd != shareLinkCreateCmd { + t.Fatalf("share-link create resolved to %q", cmd.CommandPath()) + } + + cmd, _, err = RootCmd.Find([]string{"share-link", "list"}) + if err != nil { + t.Fatalf("find share-link list: %v", err) + } + if cmd != shareLinkListCmd { + t.Fatalf("share-link list resolved to %q", cmd.CommandPath()) + } + + cmd, _, err = RootCmd.Find([]string{"share-link", "revoke"}) + if err != nil { + t.Fatalf("find share-link revoke: %v", err) + } + if cmd != shareLinkRevokeCmd { + t.Fatalf("share-link revoke resolved to %q", cmd.CommandPath()) + } +} diff --git a/cmd/share_link_revoke.go b/cmd/share_link_revoke.go new file mode 100644 index 0000000..99d6976 --- /dev/null +++ b/cmd/share_link_revoke.go @@ -0,0 +1,52 @@ +// Copyright © 2016 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "errors" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" + "github.com/spf13/cobra" +) + +func shareLinkRevoke(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("`share-link revoke` requires a `url` argument") + } + + url := args[0] + if url == "" { + return errors.New("`share-link revoke` requires a non-empty URL") + } + + dbx := newSharedLinkClient(config) + arg := sharing.NewRevokeSharedLinkArg(url) + if err := dbx.RevokeSharedLink(arg); err != nil { + return err + } + + commandVerboseStatus(cmd, "Revoked shared link %s", url) + return nil +} + +var shareLinkRevokeCmd = &cobra.Command{ + Use: "revoke ", + Short: "Revoke a shared link", + RunE: shareLinkRevoke, +} + +func init() { + shareLinkCmd.AddCommand(shareLinkRevokeCmd) +} diff --git a/cmd/share_link_revoke_test.go b/cmd/share_link_revoke_test.go new file mode 100644 index 0000000..d4c74d3 --- /dev/null +++ b/cmd/share_link_revoke_test.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" + "github.com/spf13/cobra" +) + +func TestShareLinkRevokeRequiresExactlyOneURL(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "missing url", args: nil}, + {name: "too many urls", args: []string{"http://a", "http://b"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + called = true + return nil + }, + }) + + err := shareLinkRevoke(&cobra.Command{}, tt.args) + if err == nil || !strings.Contains(err.Error(), "requires a `url` argument") { + t.Fatalf("error = %v, want url argument error", err) + } + if called { + t.Fatal("RevokeSharedLink should not be called") + } + }) + } +} + +func TestShareLinkRevokeRejectsEmptyURL(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + called = true + return nil + }, + }) + + err := shareLinkRevoke(&cobra.Command{}, []string{""}) + if err == nil || !strings.Contains(err.Error(), "non-empty URL") { + t.Fatalf("error = %v, want non-empty URL error", err) + } + if called { + t.Fatal("RevokeSharedLink should not be called") + } +} + +func TestShareLinkRevokeCallsAPIWithURL(t *testing.T) { + var revokedURL string + stubSharedLinkClient(t, &mockSharedLinkClient{ + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + revokedURL = arg.Url + return nil + }, + }) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + + err := shareLinkRevoke(cmd, []string{"https://www.dropbox.com/s/abc123"}) + if err != nil { + t.Fatalf("shareLinkRevoke error: %v", err) + } + if revokedURL != "https://www.dropbox.com/s/abc123" { + t.Fatalf("revoked URL = %q, want https://www.dropbox.com/s/abc123", revokedURL) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestShareLinkRevokeVerboseWritesStatusToStderr(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + return nil + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", true, "") + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + err := shareLinkRevoke(cmd, []string{"https://www.dropbox.com/s/abc123"}) + if err != nil { + t.Fatalf("shareLinkRevoke error: %v", err) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if got, want := stderr.String(), "Revoked shared link https://www.dropbox.com/s/abc123\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestShareLinkRevokeReturnsAPIError(t *testing.T) { + wantErr := fmt.Errorf("shared_link_not_found") + stubSharedLinkClient(t, &mockSharedLinkClient{ + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + return wantErr + }, + }) + + err := shareLinkRevoke(&cobra.Command{}, []string{"https://www.dropbox.com/s/abc123"}) + if err != wantErr { + t.Fatalf("error = %v, want API error", err) + } +} + +func TestShareLinkRevokeDoesNotBreakOtherCommands(t *testing.T) { + cmd, _, err := RootCmd.Find([]string{"share-link", "revoke"}) + if err != nil { + t.Fatalf("find share-link revoke: %v", err) + } + if cmd != shareLinkRevokeCmd { + t.Fatalf("share-link revoke resolved to %q", cmd.CommandPath()) + } + + cmd, _, err = RootCmd.Find([]string{"share-link", "create"}) + if err != nil { + t.Fatalf("find share-link create: %v", err) + } + if cmd != shareLinkCreateCmd { + t.Fatalf("share-link create resolved to %q", cmd.CommandPath()) + } + + cmd, _, err = RootCmd.Find([]string{"share-link", "list"}) + if err != nil { + t.Fatalf("find share-link list: %v", err) + } + if cmd != shareLinkListCmd { + t.Fatalf("share-link list resolved to %q", cmd.CommandPath()) + } +} diff --git a/cmd/share_link_update.go b/cmd/share_link_update.go index 763b711..29d50c4 100644 --- a/cmd/share_link_update.go +++ b/cmd/share_link_update.go @@ -31,12 +31,12 @@ type shareLinkUpdateOptions struct { func shareLinkUpdate(cmd *cobra.Command, args []string) error { if len(args) != 1 { - return errors.New("`share link update` requires a `url` argument") + return errors.New("`share-link update` requires a `url` argument") } url := args[0] if url == "" { - return errors.New("`share link update` requires a non-empty URL") + return errors.New("`share-link update` requires a non-empty URL") } opts, err := parseShareLinkUpdateOptions(cmd) @@ -60,10 +60,7 @@ func shareLinkUpdate(cmd *cobra.Command, args []string) error { return err } - verbose, _ := cmd.Flags().GetBool("verbose") - if verbose { - commandOutput(cmd).Status("Updated shared link %s", url) - } + commandVerboseStatus(cmd, "Updated shared link %s", url) return nil } diff --git a/cmd/share_link_update_test.go b/cmd/share_link_update_test.go index ba60979..137c305 100644 --- a/cmd/share_link_update_test.go +++ b/cmd/share_link_update_test.go @@ -276,12 +276,12 @@ func TestShareLinkUpdateReturnsAPIErrors(t *testing.T) { } func TestShareLinkUpdateCommandIsRegistered(t *testing.T) { - cmd, _, err := RootCmd.Find([]string{"share", "link", "update", "https://example.com/link"}) + cmd, _, err := RootCmd.Find([]string{"share-link", "update", "https://example.com/link"}) if err != nil { - t.Fatalf("find share link update: %v", err) + t.Fatalf("find share-link update: %v", err) } if cmd != shareLinkUpdateCmd { - t.Fatalf("share link update resolved to %q", cmd.CommandPath()) + t.Fatalf("share-link update resolved to %q", cmd.CommandPath()) } }