diff --git a/README.md b/README.md index 278e15d..eb8c4a7 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ $ dbxcli share-link info --path /nested/file.txt # display information for $ 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 revoke --path /file.txt # revoke direct shared links for a path $ dbxcli share-link update --allow-download # update shared link settings $ dbxcli share-link update --disallow-download # disable downloads from a shared link $ dbxcli share-link update --audience public # update shared link audience @@ -258,6 +259,8 @@ $ dbxcli share list folder # list shared folders `share-link create --audience` and `share-link update --audience` support `public`, `team`, `members`, and `no-one`. Dropbox team and folder policies can still resolve the effective audience differently. +Dropbox account, team, and folder policies can reject shared-link settings such as passwords, expiration, audience, or disabled downloads. In that case, dbxcli returns the Dropbox API error, for example `settings_error/not_authorized/`. + `share-link create`, `share-link update`, `share-link info`, and `share-link download` support `--password `, `--password-prompt`, and `--password-file ` for password-protected links. Use `--password-prompt` for interactive use so the password is not echoed. `share-link download` writes to the metadata filename when `target` is omitted. Use `-` as the target to write file bytes to stdout. Folder shared links require `--recursive` and cannot be written to stdout. diff --git a/cmd/share_link_revoke.go b/cmd/share_link_revoke.go index 99d6976..d26c92f 100644 --- a/cmd/share_link_revoke.go +++ b/cmd/share_link_revoke.go @@ -16,12 +16,26 @@ package cmd import ( "errors" + "fmt" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" ) +type shareLinkRevokeOptions struct { + path string +} + func shareLinkRevoke(cmd *cobra.Command, args []string) error { + opts, err := parseShareLinkRevokeOptions(cmd, args) + if err != nil { + return err + } + + if opts.path != "" { + return revokeSharedLinksForPath(cmd, opts.path) + } + if len(args) != 1 { return errors.New("`share-link revoke` requires a `url` argument") } @@ -41,12 +55,74 @@ func shareLinkRevoke(cmd *cobra.Command, args []string) error { return nil } +func parseShareLinkRevokeOptions(cmd *cobra.Command, args []string) (shareLinkRevokeOptions, error) { + var opts shareLinkRevokeOptions + + if !localFlagChanged(cmd, "path") { + return opts, nil + } + if len(args) != 0 { + return opts, errors.New("`--path` cannot be used with a shared link URL") + } + + pathArg, err := localStringFlag(cmd, "path") + if err != nil { + return opts, err + } + if pathArg == "" { + return opts, errors.New("`--path` requires a non-empty path") + } + + path, err := validatePath(pathArg) + if err != nil { + return opts, err + } + if path == "" { + return opts, errors.New("cannot revoke shared links for Dropbox root") + } + + opts.path = path + return opts, nil +} + +func revokeSharedLinksForPath(cmd *cobra.Command, path string) error { + arg := sharing.NewListSharedLinksArg() + arg.Path = path + arg.DirectOnly = true + + dbx := newSharedLinkClient(config) + links, err := listSharedLinks(dbx, arg) + if err != nil { + return err + } + if len(links) == 0 { + return fmt.Errorf("no direct shared links found for %q", path) + } + + for _, link := range links { + url, ok := sharedLinkURL(link) + if !ok { + return errors.New("shared link response did not include a URL") + } + if err := dbx.RevokeSharedLink(sharing.NewRevokeSharedLinkArg(url)); err != nil { + return fmt.Errorf("revoke shared link %s: %w", url, err) + } + } + + commandVerboseStatus(cmd, "Revoked %d shared links for %s", len(links), path) + return nil +} + var shareLinkRevokeCmd = &cobra.Command{ - Use: "revoke ", - Short: "Revoke a shared link", - RunE: shareLinkRevoke, + Use: "revoke [url]", + Short: "Revoke shared links", + Long: "Revoke a shared link by URL, or revoke all direct shared links for a Dropbox path with --path.", + Example: ` dbxcli share-link revoke https://www.dropbox.com/s/example/file.txt + dbxcli share-link revoke --path /file.txt`, + RunE: shareLinkRevoke, } func init() { + shareLinkRevokeCmd.Flags().String("path", "", "Revoke direct shared links for a Dropbox path") shareLinkCmd.AddCommand(shareLinkRevokeCmd) } diff --git a/cmd/share_link_revoke_test.go b/cmd/share_link_revoke_test.go index d4c74d3..54ef528 100644 --- a/cmd/share_link_revoke_test.go +++ b/cmd/share_link_revoke_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "errors" "fmt" "strings" "testing" @@ -123,6 +124,267 @@ func TestShareLinkRevokeReturnsAPIError(t *testing.T) { } } +func TestShareLinkRevokePathRequiresNoURL(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + called = true + return nil, nil + }, + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + called = true + return nil + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", "/docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkRevoke(cmd, []string{"https://www.dropbox.com/s/abc123"}) + if err == nil || !strings.Contains(err.Error(), "`--path` cannot be used with a shared link URL") { + t.Fatalf("error = %v, want path and URL conflict error", err) + } + if called { + t.Fatal("shared link API should not be called") + } +} + +func TestShareLinkRevokePathRejectsEmptyPath(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + called = true + return nil, nil + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", ""); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkRevoke(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "`--path` requires a non-empty path") { + t.Fatalf("error = %v, want empty path error", err) + } + if called { + t.Fatal("ListSharedLinks should not be called") + } +} + +func TestShareLinkRevokePathRejectsRoot(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + called = true + return nil, nil + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", "/"); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkRevoke(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "cannot revoke shared links for Dropbox root") { + t.Fatalf("error = %v, want root path error", err) + } + if called { + t.Fatal("ListSharedLinks should not be called") + } +} + +func TestShareLinkRevokePathListsDirectLinksAndRevokesAll(t *testing.T) { + var listArg *sharing.ListSharedLinksArg + var revoked []string + 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/one"), + sharedLinkFile("/docs/file.txt", "https://example.com/two"), + }, false), nil + }, + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + revoked = append(revoked, arg.Url) + return nil + }, + }) + + var stdout bytes.Buffer + cmd := newShareLinkRevokeTestCommand(&stdout, nil) + if err := cmd.Flags().Set("path", "docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + + if err := shareLinkRevoke(cmd, nil); err != nil { + t.Fatalf("shareLinkRevoke 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") + } + if got := strings.Join(revoked, ","); got != "https://example.com/one,https://example.com/two" { + t.Fatalf("revoked URLs = %q, want both direct links", got) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestShareLinkRevokePathFollowsPagination(t *testing.T) { + var cursors []string + var revoked []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/file.txt", "https://example.com/one"), + }, true) + res.Cursor = "next-page" + return res, nil + } + return sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/file.txt", "https://example.com/two"), + }, false), nil + }, + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + revoked = append(revoked, arg.Url) + return nil + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", "/docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + + if err := shareLinkRevoke(cmd, nil); err != nil { + t.Fatalf("shareLinkRevoke error: %v", err) + } + if got := strings.Join(cursors, ","); got != ",next-page" { + t.Fatalf("cursors = %q, want first call then next-page", got) + } + if got := strings.Join(revoked, ","); got != "https://example.com/one,https://example.com/two" { + t.Fatalf("revoked URLs = %q, want both pages", got) + } +} + +func TestShareLinkRevokePathReturnsErrorWhenNoLinksFound(t *testing.T) { + calledRevoke := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + return sharing.NewListSharedLinksResult(nil, false), nil + }, + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + calledRevoke = true + return nil + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", "/docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkRevoke(cmd, nil) + if err == nil || !strings.Contains(err.Error(), `no direct shared links found for "/docs/file.txt"`) { + t.Fatalf("error = %v, want no links found error", err) + } + if calledRevoke { + t.Fatal("RevokeSharedLink should not be called") + } +} + +func TestShareLinkRevokePathReturnsListError(t *testing.T) { + wantErr := fmt.Errorf("list failed") + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + return nil, wantErr + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", "/docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkRevoke(cmd, nil) + if err != wantErr { + t.Fatalf("error = %v, want list error", err) + } +} + +func TestShareLinkRevokePathReturnsRevokeError(t *testing.T) { + wantErr := fmt.Errorf("revoke failed") + stubSharedLinkClient(t, &mockSharedLinkClient{ + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + return sharing.NewListSharedLinksResult([]sharing.IsSharedLinkMetadata{ + sharedLinkFile("/docs/file.txt", "https://example.com/one"), + }, false), nil + }, + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + return wantErr + }, + }) + + cmd := newShareLinkRevokeTestCommand(nil, nil) + if err := cmd.Flags().Set("path", "/docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkRevoke(cmd, nil) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want wrapped revoke error", err) + } + if !strings.Contains(err.Error(), "revoke shared link https://example.com/one") { + t.Fatalf("error = %v, want failing URL context", err) + } +} + +func TestShareLinkRevokePathVerboseWritesStatusToStderr(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/one"), + sharedLinkFile("/docs/file.txt", "https://example.com/two"), + }, false), nil + }, + revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { + return nil + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := newShareLinkRevokeTestCommand(&stdout, &stderr) + if err := cmd.Flags().Set("path", "/docs/file.txt"); err != nil { + t.Fatalf("set path: %v", err) + } + if err := cmd.Flags().Set("verbose", "true"); err != nil { + t.Fatalf("set verbose: %v", err) + } + + if err := shareLinkRevoke(cmd, nil); 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 2 shared links for /docs/file.txt\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + func TestShareLinkRevokeDoesNotBreakOtherCommands(t *testing.T) { cmd, _, err := RootCmd.Find([]string{"share-link", "revoke"}) if err != nil { @@ -131,6 +393,12 @@ func TestShareLinkRevokeDoesNotBreakOtherCommands(t *testing.T) { if cmd != shareLinkRevokeCmd { t.Fatalf("share-link revoke resolved to %q", cmd.CommandPath()) } + if shareLinkRevokeCmd.Flags().Lookup("path") == nil { + t.Fatal("share-link revoke should define --path") + } + if shareLinkRevokeCmd.Use != "revoke [url]" { + t.Fatalf("share-link revoke use = %q, want optional URL", shareLinkRevokeCmd.Use) + } cmd, _, err = RootCmd.Find([]string{"share-link", "create"}) if err != nil { @@ -148,3 +416,16 @@ func TestShareLinkRevokeDoesNotBreakOtherCommands(t *testing.T) { t.Fatalf("share-link list resolved to %q", cmd.CommandPath()) } } + +func newShareLinkRevokeTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().String("path", "", "") + cmd.Flags().Bool("verbose", false, "") + if stdout != nil { + cmd.SetOut(stdout) + } + if stderr != nil { + cmd.SetErr(stderr) + } + return cmd +}