Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions cmd/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type accountInput struct {
type jsonAccount struct {
Type string `json:"type"`
AccountID string `json:"account_id"`
Auth *accountAuth `json:"auth,omitempty"`
Name *jsonAccountName `json:"name,omitempty"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Expand Down Expand Up @@ -58,13 +59,19 @@ type jsonAccountTeam struct {
MemberID string `json:"member_id,omitempty"`
}

type accountAuth struct {
Source string `json:"source"`
Refreshable bool `json:"refreshable"`
AuthFile string `json:"auth_file,omitempty"`
}

const (
accountJSONStatusFound = "found"
accountKindAccount = "account"
)

// renderFullAccount prints the account details returned by GetCurrentAccount.
func renderFullAccount(out io.Writer, fa *users.FullAccount) error {
func renderFullAccount(out io.Writer, fa *users.FullAccount, auth *accountAuth) error {
w := new(tabwriter.Writer)
w.Init(out, 4, 8, 1, ' ', 0)

Expand All @@ -73,17 +80,20 @@ func renderFullAccount(out io.Writer, fa *users.FullAccount) error {
fmt.Fprintf(w, "Account Type:\t%s\n", fa.AccountType.Tag)
fmt.Fprintf(w, "Locale:\t%s\n", fa.Locale)
fmt.Fprintf(w, "Referral Link:\t%s\n", fa.ReferralLink)
fmt.Fprintf(w, "Profile Photo Url:\t%s\n", fa.ProfilePhotoUrl)
if fa.ProfilePhotoUrl != "" {
fmt.Fprintf(w, "Profile Photo URL:\t%s\n", fa.ProfilePhotoUrl)
}
fmt.Fprintf(w, "Paired Account:\t%t\n", fa.IsPaired)
if fa.Team != nil {
fmt.Fprintf(w, "Team:\n Name:\t%s\n Id:\t%s\n Member Id:\t%s\n", fa.Team.Name, fa.Team.Id, fa.TeamMemberId)
}
renderAccountAuth(w, auth)

return w.Flush()
}

// renderBasicAccount prints the account details returned by GetAccount.
func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error {
func renderBasicAccount(out io.Writer, ba *users.BasicAccount, auth *accountAuth) error {
w := new(tabwriter.Writer)
w.Init(out, 4, 8, 1, ' ', 0)

Expand All @@ -97,18 +107,45 @@ func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error {
if ba.TeamMemberId != "" {
fmt.Fprintf(w, "Team Member Id:\t%s\n", ba.TeamMemberId)
}
fmt.Fprintf(w, "Profile Photo URL:\t%s\n", ba.ProfilePhotoUrl)
if ba.ProfilePhotoUrl != "" {
fmt.Fprintf(w, "Profile Photo URL:\t%s\n", ba.ProfilePhotoUrl)
}
renderAccountAuth(w, auth)

return w.Flush()
}

func renderAccountAuth(out io.Writer, auth *accountAuth) {
if auth == nil {
return
}

authFile := auth.AuthFile
if authFile == "" {
authFile = authFileNone
}
fmt.Fprintf(out, "\nAuth:\n Source:\t%s\n Refreshable:\t%t\n Auth File:\t%s\n", accountAuthSourceText(auth.Source), auth.Refreshable, authFile)
}

func accountAuthSourceText(source string) string {
switch source {
case authSourceSaved:
return "saved credentials"
case authSourceEnv:
return envAccessToken
default:
return source
}
}

func account(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return invalidArgumentsErrorWithDetails("`account` accepts an optional `id` argument", argumentErrorDetails("id"))
}

dbx := usersNewFunc(config)
out := commandOutput(cmd)
auth := accountAuthFromContext(currentAuthContext)

if len(args) == 0 {
// If no arguments are provided get the current user's account
Expand All @@ -118,8 +155,8 @@ func account(cmd *cobra.Command, args []string) error {
}
input := accountInput{}
return out.Render(func(w io.Writer) error {
return renderFullAccount(w, res)
}, withJSONCommand(cmd, newAccountOperationOutput(input, jsonFullAccount(res))))
return renderFullAccount(w, res, auth)
}, withJSONCommand(cmd, newAccountOperationOutput(input, jsonFullAccount(res, auth))))
}

// Otherwise look up an account with the provided ID
Expand All @@ -132,8 +169,8 @@ func account(cmd *cobra.Command, args []string) error {
AccountID: args[0],
}
return out.Render(func(w io.Writer) error {
return renderBasicAccount(w, res)
}, withJSONCommand(cmd, newAccountOperationOutput(input, jsonBasicAccount(res))))
return renderBasicAccount(w, res, auth)
}, withJSONCommand(cmd, newAccountOperationOutput(input, jsonBasicAccount(res, auth))))
}

func newAccountOperationOutput(input accountInput, account jsonAccount) jsonOperationOutput {
Expand All @@ -142,8 +179,9 @@ func newAccountOperationOutput(input accountInput, account jsonAccount) jsonOper
}, nil)
}

func jsonFullAccount(fa *users.FullAccount) jsonAccount {
func jsonFullAccount(fa *users.FullAccount, auth *accountAuth) jsonAccount {
account := jsonAccountFromBase("full", fa.Account)
account.Auth = auth
account.Locale = fa.Locale
account.ReferralLink = fa.ReferralLink
account.IsPaired = boolPtr(fa.IsPaired)
Expand All @@ -161,13 +199,25 @@ func jsonFullAccount(fa *users.FullAccount) jsonAccount {
return account
}

func jsonBasicAccount(ba *users.BasicAccount) jsonAccount {
func jsonBasicAccount(ba *users.BasicAccount, auth *accountAuth) jsonAccount {
account := jsonAccountFromBase("basic", ba.Account)
account.Auth = auth
account.IsTeammate = boolPtr(ba.IsTeammate)
account.TeamMemberID = ba.TeamMemberId
return account
}

func accountAuthFromContext(ctx *authContext) *accountAuth {
if ctx == nil || ctx.Source == "" {
return nil
}
return &accountAuth{
Source: ctx.Source,
Refreshable: ctx.Refreshable,
AuthFile: ctx.AuthFile,
}
}

func jsonAccountFromBase(accountType string, account users.Account) jsonAccount {
return jsonAccount{
Type: accountType,
Expand Down
63 changes: 63 additions & 0 deletions cmd/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

func TestAccountCurrentTextUsesCommandOutput(t *testing.T) {
cmd, stdout := testAccountCmd()
setCurrentAuthContextForTest(t, &authContext{Source: authSourceSaved, Refreshable: true, AuthFile: authFileDefault})
stubUsersClient(t, &mockUsersClient{
getCurrentAccountFn: func() (*users.FullAccount, error) {
return testFullAccount(), nil
Expand All @@ -31,11 +32,24 @@ func TestAccountCurrentTextUsesCommandOutput(t *testing.T) {
if !strings.Contains(output, "Account Type:") {
t.Fatalf("stdout = %q, want account type", output)
}
if !strings.Contains(output, "Auth:") || !strings.Contains(output, "Source: saved credentials") || !strings.Contains(output, "Refreshable: true") || !strings.Contains(output, "Auth File: default") {
t.Fatalf("stdout = %q, want auth context", output)
}
if !strings.Contains(output, "Profile Photo URL:") {
t.Fatalf("stdout = %q, want Profile Photo URL label", output)
}
if strings.Contains(output, "Profile Photo Url:") {
t.Fatalf("stdout = %q, want URL acronym casing", output)
}
if strings.Contains(output, "Acting As:") {
t.Fatalf("stdout = %q, want no Acting As section", output)
}
}

func TestAccountCurrentJSONOutputsAccount(t *testing.T) {
cmd, stdout := testAccountCmd()
setAccountOutputJSON(t, cmd)
setCurrentAuthContextForTest(t, &authContext{Source: authSourceSaved, Refreshable: true, AuthFile: authFileDefault})
stubUsersClient(t, &mockUsersClient{
getCurrentAccountFn: func() (*users.FullAccount, error) {
return testFullAccount(), nil
Expand Down Expand Up @@ -73,11 +87,13 @@ func TestAccountCurrentJSONOutputsAccount(t *testing.T) {
if account.Team == nil || account.Team.ID != "team-id" || account.Team.MemberID != "dbmid:member" {
t.Fatalf("team = %#v, want team metadata", account.Team)
}
assertAccountAuth(t, account.Auth, authSourceSaved, true, authFileDefault)
}

func TestAccountLookupJSONUsesAccountID(t *testing.T) {
cmd, stdout := testAccountCmd()
setAccountOutputJSON(t, cmd)
setCurrentAuthContextForTest(t, &authContext{Source: authSourceEnv, Refreshable: false, AuthFile: authFileNone})
var gotArg *users.GetAccountArg
stubUsersClient(t, &mockUsersClient{
getAccountFn: func(arg *users.GetAccountArg) (*users.BasicAccount, error) {
Expand Down Expand Up @@ -111,6 +127,32 @@ func TestAccountLookupJSONUsesAccountID(t *testing.T) {
if account.IsTeammate == nil || *account.IsTeammate {
t.Fatalf("is_teammate = %#v, want false pointer", account.IsTeammate)
}
assertAccountAuth(t, account.Auth, authSourceEnv, false, authFileNone)
}

func TestAccountLookupTextIncludesAuth(t *testing.T) {
cmd, stdout := testAccountCmd()
setCurrentAuthContextForTest(t, &authContext{Source: authSourceEnv, Refreshable: false, AuthFile: authFileNone})
stubUsersClient(t, &mockUsersClient{
getAccountFn: func(arg *users.GetAccountArg) (*users.BasicAccount, error) {
return testBasicAccount(), nil
},
})

if err := account(cmd, []string{"dbid:lookup"}); err != nil {
t.Fatalf("account returned error: %v", err)
}

output := stdout.String()
if !strings.Contains(output, "Auth:") || !strings.Contains(output, "Source: DBXCLI_ACCESS_TOKEN") || !strings.Contains(output, "Refreshable: false") || !strings.Contains(output, "Auth File: none") {
t.Fatalf("stdout = %q, want env auth context", output)
}
if strings.Contains(output, "Profile Photo URL:") {
t.Fatalf("stdout = %q, want empty profile photo omitted", output)
}
if strings.Contains(output, "Acting As:") {
t.Fatalf("stdout = %q, want no Acting As section", output)
}
}

func TestAccountJSONErrorWritesNoOutput(t *testing.T) {
Expand Down Expand Up @@ -151,6 +193,16 @@ func setAccountOutputJSON(t *testing.T, cmd *cobra.Command) {
}
}

func setCurrentAuthContextForTest(t *testing.T, ctx *authContext) {
t.Helper()

orig := currentAuthContext
currentAuthContext = ctx
t.Cleanup(func() {
currentAuthContext = orig
})
}

type accountJSONOutput struct {
Input accountInput `json:"input"`
Results []accountJSONResult `json:"results"`
Expand Down Expand Up @@ -182,6 +234,17 @@ func decodeAccountOutput(t *testing.T, out *bytes.Buffer) accountJSONOutput {
return got
}

func assertAccountAuth(t *testing.T, got *accountAuth, source string, refreshable bool, authFile string) {
t.Helper()

if got == nil {
t.Fatal("auth = nil, want auth context")
}
if got.Source != source || got.Refreshable != refreshable || got.AuthFile != authFile {
t.Fatalf("auth = %#v, want source=%q refreshable=%t auth_file=%q", got, source, refreshable, authFile)
}
}

func testFullAccount() *users.FullAccount {
account := users.NewFullAccount(
"dbid:current",
Expand Down
48 changes: 39 additions & 9 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ const (
tokenAccessTypeParam = "token_access_type"
tokenAccessTypeOffline = "offline"
tokenRefreshWindow = 5 * time.Minute

authSourceEnv = "env"
authSourceSaved = "saved"

authFileDefault = "default"
authFileCustom = "custom"
authFileNone = "none"
)

// TokenMap maps domains to a map of commands to saved credentials.
Expand All @@ -54,6 +61,14 @@ type appCredentials struct {
Key string
}

type authContext struct {
Source string
Refreshable bool
AuthFile string
}

var currentAuthContext *authContext

var readAppKey = func(prompt string) (string, error) {
fmt.Print(prompt)
var value string
Expand Down Expand Up @@ -174,6 +189,13 @@ func authFilePath() (string, error) {
return filepath.Join(dir, ".config", "dbxcli", configFileName), nil
}

func authFileKind() string {
if os.Getenv(envAuthFile) != "" {
return authFileCustom
}
return authFileDefault
}

func readTokens(filePath string) (TokenMap, error) {
b, err := os.ReadFile(filePath)
if err != nil {
Expand Down Expand Up @@ -202,15 +224,15 @@ func writeTokens(filePath string, tokens TokenMap) error {
return os.WriteFile(filePath, b, 0600)
}

func getAccessToken(tokType string, domain string, force bool) (string, string, error) {
func getAccessCredential(tokType string, domain string, force bool) (storedCredential, string, error) {
filePath, err := authFilePath()
if err != nil {
return "", "", err
return storedCredential{}, "", err
}

tokenMap, err := readTokens(filePath)
if err != nil && !os.IsNotExist(err) {
return "", "", fmt.Errorf("read auth file %q: %w", filePath, err)
return storedCredential{}, "", fmt.Errorf("read auth file %q: %w", filePath, err)
}
if tokenMap == nil {
tokenMap = make(TokenMap)
Expand All @@ -223,15 +245,15 @@ func getAccessToken(tokType string, domain string, force bool) (string, string,

if force || (credential.AccessToken == "" && credential.RefreshToken == "") {
if !force {
return "", "", missingAccessTokenError(tokType)
return storedCredential{}, "", missingAccessTokenError(tokType)
}
credential, err = requestAccessCredential(tokType, domain)
if err != nil {
return "", "", err
return storedCredential{}, "", err
}
tokens[tokType] = credential
if err = writeTokens(filePath, tokenMap); err != nil {
return "", "", err
return storedCredential{}, "", err
}
}

Expand All @@ -240,16 +262,24 @@ func getAccessToken(tokType string, domain string, force bool) (string, string,
if err != nil {
details := authTokenDetails(tokType)
if jsonErrorCode(err) == jsonErrorCodeAppKeyRequired {
return "", "", appKeyRequiredErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType))
return storedCredential{}, "", appKeyRequiredErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType))
}
return "", "", authRefreshFailedErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType))
return storedCredential{}, "", authRefreshFailedErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType))
}
tokens[tokType] = credential
if err = writeTokens(filePath, tokenMap); err != nil {
return "", "", err
return storedCredential{}, "", err
}
}

return credential, filePath, nil
}

func getAccessToken(tokType string, domain string, force bool) (string, string, error) {
credential, filePath, err := getAccessCredential(tokType, domain, force)
if err != nil {
return "", "", err
}
return credential.AccessToken, filePath, nil
}

Expand Down
Loading
Loading