diff --git a/README.md b/README.md index 84f878f..14e32b0 100644 --- a/README.md +++ b/README.md @@ -165,11 +165,33 @@ $ dbxcli rm --output=json /old-file.txt $ dbxcli restore --output=json /Reports/old.pdf 015f... ``` -Structured success output is rolling out command by command. Currently migrated commands are `version`, `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `share list folder`, `team info`, `team list-members`, `team list-groups`, `team add-member`, `team remove-member`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`. +Structured success output is rolling out command by command. Currently migrated commands are `version`, `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `share list folder`, `share list link`, `team info`, `team list-members`, `team list-groups`, `team add-member`, `team remove-member`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`. Command results and JSON errors are written to stdout. Status, progress, human-facing warnings, diagnostics, and verbose logs are written to stderr. JSON errors include a `warnings` array for machine-actionable warnings; it is `[]` when no warnings are present. Successful JSON payloads use the same `warnings` field. Current warning codes include `deprecated_command` for deprecated command paths and `skipped_symlink` for symlinks skipped by recursive upload. +Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Cobra help output and shell-completion protocol commands are also text-only. + +JSON error responses use stable `error.code` values: + +| Code | Meaning | +|---------------------------------|-----------------------------------------------------------------------------------| +| `invalid_arguments` | The command arguments or flags are invalid. | +| `path_conflict` | A local or Dropbox path conflicts with the requested operation. | +| `auth_required` | No usable saved credentials were found, or Dropbox rejected the saved token. | +| `auth_refresh_failed` | Saved refreshable credentials could not be refreshed. | +| `app_key_required` | Login or token refresh needs a Dropbox app key. | +| `auth_exchange_failed` | The OAuth authorization-code exchange failed or returned unusable tokens. | +| `not_found` | Dropbox reported that the requested object was not found. | +| `permission_denied` | Dropbox denied access because of permissions, scope, member selection, or state. | +| `rate_limited` | Dropbox rate limited the request. | +| `dropbox_api_error` | Dropbox returned an API error that does not map to a more specific code yet. | +| `structured_output_unsupported` | The command does not support `--output=json` yet. | +| `unsupported_output_format` | `--output` was not `text` or `json`. | +| `unknown_command` | Cobra could not resolve the command. | +| `unknown_flag` | Cobra could not resolve a flag. | +| `command_failed` | Fallback for failures without a more specific stable code. | + Successful JSON responses for migrated commands return an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. For commands such as `mkdir`, each result reports what happened to the requested path: ```json @@ -466,7 +488,7 @@ In JSON mode, command errors are written to stdout as JSON, including errors fro } ``` -Error `code` values are stable identifiers intended for scripts. Current codes are `structured_output_unsupported`, `unsupported_output_format`, `unknown_command`, `unknown_flag`, `path_conflict`, `invalid_arguments`, and `command_failed`. +Error `code` values are stable identifiers intended for scripts. The current stable codes are listed in the table above. ### Authentication diff --git a/cmd/auth.go b/cmd/auth.go index f6ee776..4dd9fce 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -17,7 +17,6 @@ package cmd import ( "context" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -239,7 +238,10 @@ func getAccessToken(tokType string, domain string, force bool) (string, string, if !force && credential.shouldRefresh(time.Now()) { credential, err = refreshStoredCredential(tokType, domain, credential) if err != nil { - return "", "", fmt.Errorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType)) + if jsonErrorCode(err) == jsonErrorCodeAppKeyRequired { + return "", "", appKeyRequiredErrorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType)) + } + return "", "", authRefreshFailedErrorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType)) } tokens[tokType] = credential if err = writeTokens(filePath, tokenMap); err != nil { @@ -262,7 +264,7 @@ func loginCommand(tokType string) string { } func missingAccessTokenError(tokType string) error { - return fmt.Errorf("no saved Dropbox credentials; run %q first or set %s", loginCommand(tokType), envAccessToken) + return authRequiredErrorf("no saved Dropbox credentials; run %q first or set %s", loginCommand(tokType), envAccessToken) } func appCredentialsName(tokType string) string { @@ -287,7 +289,7 @@ func ensureOAuthAppCredentials(tokType string) error { } creds.Key = strings.TrimSpace(creds.Key) if creds.Key == "" { - return errors.New("Dropbox app key is required") + return appKeyRequiredError("Dropbox app key is required") } setOAuthCredentials(tokType, creds.Key) @@ -325,13 +327,13 @@ func requestAccessCredential(tokType string, domain string) (storedCredential, e } token, err := exchangeAuthorizationCode(context.Background(), conf, code, verifier) if err != nil { - return storedCredential{}, err + return storedCredential{}, authExchangeFailedErrorf("exchange authorization code: %w", err) } if token == nil || token.AccessToken == "" { - return storedCredential{}, errors.New("authorization did not return an access token") + return storedCredential{}, authExchangeFailedError("authorization did not return an access token") } if token.RefreshToken == "" { - return storedCredential{}, errors.New("authorization did not return a refresh token") + return storedCredential{}, authExchangeFailedError("authorization did not return a refresh token") } return storedCredentialFromOAuthToken(token, conf.ClientID), nil } @@ -342,7 +344,7 @@ func refreshStoredCredential(tokType string, domain string, credential storedCre appKey = oauthCredentials(tokType) } if strings.TrimSpace(appKey) == "" { - return storedCredential{}, errors.New("saved credentials cannot be refreshed without a Dropbox app key") + return storedCredential{}, appKeyRequiredError("saved credentials cannot be refreshed without a Dropbox app key") } token, err := refreshOAuthToken(context.Background(), oauthConfigWithAppKey(appKey, domain), credential.oauthToken()) @@ -350,7 +352,7 @@ func refreshStoredCredential(tokType string, domain string, credential storedCre return storedCredential{}, err } if token == nil || token.AccessToken == "" { - return storedCredential{}, errors.New("token refresh did not return an access token") + return storedCredential{}, authRefreshFailedErrorf("token refresh did not return an access token") } refreshed := storedCredentialFromOAuthToken(token, appKey) diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 1b4e05d..1db0b5b 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -371,6 +371,9 @@ func TestGetAccessTokenRefreshFailureLeavesAuthFileUnchanged(t *testing.T) { if err == nil { t.Fatal("expected refresh failure") } + if got, want := jsonErrorCode(err), jsonErrorCodeAuthRefreshFailed; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) + } if !strings.Contains(err.Error(), "dbxcli login") { t.Fatalf("expected login hint, got %q", err) } @@ -384,6 +387,43 @@ func TestGetAccessTokenRefreshFailureLeavesAuthFileUnchanged(t *testing.T) { } } +func TestGetAccessTokenRefreshWithoutAppKeyReturnsAppKeyRequired(t *testing.T) { + expired := time.Now().Add(-time.Hour).UTC() + authFile := filepath.Join(t.TempDir(), "auth.json") + tokens := TokenMap{ + "": { + tokenPersonal: { + AccessToken: "old-access", + RefreshToken: "old-refresh", + TokenType: "Bearer", + Expiry: &expired, + }, + }, + } + if err := writeTokens(authFile, tokens); err != nil { + t.Fatal(err) + } + t.Setenv(envAuthFile, authFile) + + restoreOAuthCredentials(t) + setOAuthCredentials(tokenPersonal, "") + refreshOAuthToken = func(ctx context.Context, conf *oauth2.Config, token *oauth2.Token) (*oauth2.Token, error) { + t.Fatal("refresh should not run without an app key") + return nil, nil + } + + _, _, err := getAccessToken(tokenPersonal, "", false) + if err == nil { + t.Fatal("expected missing app key error") + } + if got, want := jsonErrorCode(err), jsonErrorCodeAppKeyRequired; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) + } + if !strings.Contains(err.Error(), "dbxcli login") { + t.Fatalf("expected login hint, got %q", err) + } +} + func TestGetAccessTokenMissingTokenWithDefaultPersonalCredentialsReturnsLoginError(t *testing.T) { restoreOAuthCredentials(t) setOAuthCredentials(tokenPersonal, defaultPersonalAppKey) @@ -415,6 +455,9 @@ func TestGetAccessTokenMissingTokenWithDefaultPersonalCredentialsReturnsLoginErr if err == nil { t.Fatal("expected missing credentials error") } + if got, want := jsonErrorCode(err), jsonErrorCodeAuthRequired; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) + } if !strings.Contains(err.Error(), "dbxcli login") { t.Fatalf("expected login hint, got %q", err) } @@ -454,6 +497,9 @@ func TestGetAccessTokenMissingTokenWithConfiguredAppKeyReturnsLoginError(t *test if err == nil { t.Fatal("expected missing credentials error") } + if got, want := jsonErrorCode(err), jsonErrorCodeAuthRequired; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) + } if !strings.Contains(err.Error(), "dbxcli login") { t.Fatalf("expected login hint, got %q", err) } @@ -495,6 +541,8 @@ func TestRequestAccessTokenRejectsEmptyToken(t *testing.T) { if _, err := requestAccessToken(tokenPersonal, ""); err == nil { t.Fatal("expected empty access token to return an error") + } else if got, want := jsonErrorCode(err), jsonErrorCodeAuthExchangeFailed; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) } } @@ -517,6 +565,8 @@ func TestRequestAccessTokenRejectsMissingRefreshToken(t *testing.T) { if _, err := requestAccessToken(tokenPersonal, ""); err == nil { t.Fatal("expected missing refresh token to return an error") + } else if got, want := jsonErrorCode(err), jsonErrorCodeAuthExchangeFailed; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) } } @@ -742,5 +792,7 @@ func TestRequestAccessTokenRejectsEmptyAppCredentials(t *testing.T) { if _, err := requestAccessToken(tokenTeamManage, ""); err == nil { t.Fatal("expected empty app credentials to fail") + } else if got, want := jsonErrorCode(err), jsonErrorCodeAppKeyRequired; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) } } diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index af5add7..e8b84c3 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "errors" + "os" + "reflect" "sort" "strings" "testing" @@ -13,11 +15,139 @@ import ( ) func TestStructuredOutputCommandAudit(t *testing.T) { - got := structuredOutputCommandPaths(RootCmd) - got = append(got, NewVersionCommand("test").Name()) + got := structuredOutputCommandPathsWithVersion() sort.Strings(got) - want := []string{ + want := expectedStructuredOutputCommands() + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("structured commands = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("structured commands = %v, want %v", got, want) + } + } +} + +func TestStructuredOutputSuccessFixtureAudit(t *testing.T) { + structuredCommands := structuredOutputCommandPathsWithVersion() + structuredSet := make(map[string]bool, len(structuredCommands)) + for _, command := range structuredCommands { + structuredSet[command] = true + } + + fixtures := jsonSuccessFixtureCoverage() + for _, command := range structuredCommands { + if fixtures[command].file == "" { + t.Errorf("structured command %q has no success JSON fixture coverage entry", command) + } + } + for command, fixture := range fixtures { + if fixture.file == "" { + t.Errorf("fixture coverage for %q has empty file", command) + } + if !structuredSet[command] { + t.Errorf("fixture coverage includes non-structured command %q", command) + } + if len(fixture.tests) == 0 { + t.Errorf("fixture coverage for %q has no test functions", command) + } + source, err := os.ReadFile(fixture.file) + if err != nil { + t.Errorf("read fixture file for %q: %v", command, err) + continue + } + for _, testName := range fixture.tests { + if !strings.Contains(string(source), "func "+testName+"(") { + t.Errorf("fixture coverage for %q references missing %s in %s", command, testName, fixture.file) + } + } + } +} + +func TestStructuredOutputGoldenSchemaAudit(t *testing.T) { + contract := loadJSONGoldenContract(t) + + assertStringSliceMapEqual(t, "schema definitions", contract.Definitions, jsonContractDefinitions()) + + structuredCommands := structuredOutputCommandPathsWithVersion() + structuredSet := make(map[string]bool, len(structuredCommands)) + for _, command := range structuredCommands { + structuredSet[command] = true + } + + want := jsonCommandSchemas() + for _, command := range structuredCommands { + gotSchema, ok := contract.Commands[command] + if !ok { + t.Errorf("structured command %q has no golden schema", command) + continue + } + wantSchema, ok := want[command] + if !ok { + t.Errorf("structured command %q has no code-derived schema", command) + continue + } + assertGoldenCommandSchemaEqual(t, command, gotSchema, wantSchema) + assertGoldenCommandSchemaReferences(t, command, gotSchema, contract.Definitions) + } + for command, schema := range contract.Commands { + if !structuredSet[command] { + t.Errorf("golden schema includes non-structured command %q", command) + } + assertGoldenCommandSchemaReferences(t, command, schema, contract.Definitions) + } + for command := range want { + if !structuredSet[command] { + t.Errorf("code-derived schema includes non-structured command %q", command) + } + } +} + +func TestStructuredOutputGoldenSuccessOutputAudit(t *testing.T) { + fixtures := loadJSONGoldenSuccessOutputs(t) + examples := jsonGoldenSuccessOutputExamples() + + structuredCommands := structuredOutputCommandPathsWithVersion() + structuredSet := make(map[string]bool, len(structuredCommands)) + for _, command := range structuredCommands { + structuredSet[command] = true + } + + for _, command := range structuredCommands { + fixture, ok := fixtures[command] + if !ok { + t.Errorf("structured command %q has no golden success output", command) + continue + } + example, ok := examples[command] + if !ok { + t.Errorf("structured command %q has no code-derived success output example", command) + continue + } + assertGoldenJSONEqual(t, command, fixture, example) + } + for command := range fixtures { + if !structuredSet[command] { + t.Errorf("golden success output includes non-structured command %q", command) + } + } + for command := range examples { + if !structuredSet[command] { + t.Errorf("code-derived success output example includes non-structured command %q", command) + } + } +} + +func structuredOutputCommandPathsWithVersion() []string { + paths := structuredOutputCommandPaths(RootCmd) + return append(paths, NewVersionCommand("test").Name()) +} + +func expectedStructuredOutputCommands() []string { + return []string{ "account", "cp", "du", @@ -45,16 +175,663 @@ func TestStructuredOutputCommandAudit(t *testing.T) { "team remove-member", "version", } - sort.Strings(want) +} - if len(got) != len(want) { - t.Fatalf("structured commands = %v, want %v", got, want) +type jsonSuccessFixture struct { + file string + tests []string +} + +type jsonGoldenContract struct { + Definitions map[string][]string `json:"definitions"` + Commands map[string]jsonGoldenCommandSchema `json:"commands"` +} + +type jsonGoldenCommandSchema struct { + TopLevel string `json:"top_level"` + ResultWrapper string `json:"result_wrapper"` + Input string `json:"input"` + ResultInput *string `json:"result_input"` + Result string `json:"result"` + Statuses []string `json:"statuses"` + Kinds []string `json:"kinds"` + Warnings []string `json:"warnings"` +} + +func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture { + return map[string]jsonSuccessFixture{ + "account": { + file: "account_test.go", + tests: []string{"TestAccountCurrentJSONOutputsAccount", "TestAccountLookupJSONUsesAccountID"}, + }, + "cp": { + file: "cp_test.go", + tests: []string{"TestCpJSONOutputsRelocationResults", "TestCpJSONMultipleSourcesOutputsMultipleResults"}, + }, + "du": { + file: "du_test.go", + tests: []string{"TestDuJSONIndividualAllocation", "TestDuJSONTeamAllocation"}, + }, + "get": { + file: "get_test.go", + tests: []string{"TestGetJSONFileOutputsDownloadedResult", "TestGetJSONRecursiveOutputsDirectoryAndFileResults"}, + }, + "ls": { + file: "ls_test.go", + tests: []string{"TestLsJSONListsResultsAndInput", "TestLsJSONDeletedEntryIsStructured"}, + }, + "mkdir": { + file: "mkdir_test.go", + tests: []string{"TestMkdirJSONOutputsCreatedFolder", "TestMkdirJSONParentsReturnsExistingFolderMetadata"}, + }, + "mv": { + file: "mv_test.go", + tests: []string{"TestMvJSONOutputsRelocationResults", "TestMvJSONMultipleSourcesOutputsMultipleResults"}, + }, + "put": { + file: "put_test.go", + tests: []string{"TestPutJSONSingleFileOutputsUploadedResult", "TestPutJSONRecursiveOutputsDirectoryAndFileResults"}, + }, + "restore": { + file: "restore_test.go", + tests: []string{"TestRestoreJSONOutputsInputAndMetadata"}, + }, + "revs": { + file: "revs_test.go", + tests: []string{"TestRevsJSONOutputsInputAndResults"}, + }, + "rm": { + file: "rm_test.go", + tests: []string{"TestRmJSONDeletesFile", "TestRmJSONMultipleTargets"}, + }, + "search": { + file: "search_test.go", + tests: []string{"TestSearchJSONOutputsInputAndResults", "TestSearchJSONOmitsPathWithoutScope"}, + }, + "share list folder": { + file: "share_list_folders_test.go", + tests: []string{"TestShareListFoldersJSONOutputsSharedFolders", "TestShareListFoldersJSONPaginates"}, + }, + "share list link": { + file: "share_link_json_test.go", + tests: []string{"TestDeprecatedShareListLinkJSONIncludesWarning"}, + }, + "share-link create": { + file: "share_link_json_test.go", + tests: []string{"TestShareLinkCreateJSONOutputsLinkMetadata"}, + }, + "share-link download": { + file: "share_link_json_test.go", + tests: []string{"TestShareLinkDownloadJSONOutputsTargetAndMetadata"}, + }, + "share-link info": { + file: "share_link_json_test.go", + tests: []string{"TestShareLinkInfoJSONOutputsPermissions"}, + }, + "share-link list": { + file: "share_link_json_test.go", + tests: []string{"TestShareLinkListJSONOutputsResultsAndInput"}, + }, + "share-link revoke": { + file: "share_link_json_test.go", + tests: []string{"TestShareLinkRevokeJSONOutputsRevokedURL"}, + }, + "share-link update": { + file: "share_link_json_test.go", + tests: []string{"TestShareLinkUpdateJSONOutputsUpdatedMetadata"}, + }, + "team add-member": { + file: "team_json_test.go", + tests: []string{"TestTeamAddMemberJSONOutputsMutationResult"}, + }, + "team info": { + file: "team_json_test.go", + tests: []string{"TestTeamInfoJSONOutputsTeamInfo"}, + }, + "team list-groups": { + file: "team_json_test.go", + tests: []string{"TestTeamListGroupsJSONPaginates"}, + }, + "team list-members": { + file: "team_json_test.go", + tests: []string{"TestTeamListMembersJSONPaginates"}, + }, + "team remove-member": { + file: "team_json_test.go", + tests: []string{"TestTeamRemoveMemberJSONOutputsMutationResult"}, + }, + "version": { + file: "version_test.go", + tests: []string{"TestVersionJSONOutputsVersionInfo"}, + }, } - for i := range want { - if got[i] != want[i] { - t.Fatalf("structured commands = %v, want %v", got, want) +} + +func loadJSONGoldenContract(t *testing.T) jsonGoldenContract { + t.Helper() + + data, err := os.ReadFile("testdata/json_contract/success_schemas.json") + if err != nil { + t.Fatalf("read golden schema fixture: %v", err) + } + + var raw struct { + Definitions map[string]json.RawMessage `json:"definitions"` + Commands map[string]map[string]json.RawMessage `json:"commands"` + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("decode raw golden schema fixture: %v", err) + } + if len(raw.Definitions) == 0 { + t.Fatalf("golden schema fixture has no definitions") + } + if len(raw.Commands) == 0 { + t.Fatalf("golden schema fixture has no commands") + } + + requiredCommandFields := []string{ + "top_level", + "result_wrapper", + "input", + "result_input", + "result", + "statuses", + "kinds", + "warnings", + } + for command, fields := range raw.Commands { + for _, field := range requiredCommandFields { + if _, ok := fields[field]; !ok { + t.Errorf("golden schema for %q missing %q", command, field) + } } } + + var contract jsonGoldenContract + if err := json.Unmarshal(data, &contract); err != nil { + t.Fatalf("decode golden schema fixture: %v", err) + } + return normalizeGoldenContract(contract) +} + +func loadJSONGoldenSuccessOutputs(t *testing.T) map[string]json.RawMessage { + t.Helper() + + data, err := os.ReadFile("testdata/json_contract/success_outputs.json") + if err != nil { + t.Fatalf("read golden success output fixture: %v", err) + } + + var fixtures map[string]json.RawMessage + if err := json.Unmarshal(data, &fixtures); err != nil { + t.Fatalf("decode golden success output fixture: %v", err) + } + if len(fixtures) == 0 { + t.Fatalf("golden success output fixture has no commands") + } + return fixtures +} + +func assertGoldenJSONEqual(t *testing.T, command string, fixture json.RawMessage, actual any) { + t.Helper() + + actualJSON, err := json.Marshal(actual) + if err != nil { + t.Fatalf("marshal JSON example for %q: %v", command, err) + } + + var got any + if err := json.Unmarshal(fixture, &got); err != nil { + t.Fatalf("decode golden output for %q: %v", command, err) + } + var want any + if err := json.Unmarshal(actualJSON, &want); err != nil { + t.Fatalf("decode generated output for %q: %v", command, err) + } + if reflect.DeepEqual(got, want) { + return + } + + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Errorf("golden output for %q = %s, want %s", command, gotJSON, wantJSON) +} + +func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { + file := sampleJSONFileMetadata("/Reports/old.pdf") + copyFile := sampleJSONFileMetadata("/Reports/copy.pdf") + folder := sampleJSONFolderMetadata("/Reports") + sharedLink := sampleShareLinkJSONMetadata() + teamMember := sampleTeamMemberJSON() + + return map[string]jsonOperationOutput{ + "account": newJSONOperationOutput(accountInput{AccountID: "dbid:lookup"}, []jsonOperationResult{ + newJSONOperationResult("", accountKindAccount, accountInput{AccountID: "dbid:lookup"}, sampleJSONAccount()), + }, nil), + "cp": newJSONOperationOutput(nil, []jsonOperationResult{ + newJSONOperationResult("", "", relocationInput{FromPath: "/Reports/old.pdf", ToPath: "/Reports/copy.pdf"}, copyFile), + }, nil), + "du": newJSONOperationOutput(duInput{}, []jsonOperationResult{ + newJSONOperationResult("", duKindSpaceUsage, duInput{}, duOutput{ + Used: 2048, + Allocation: duAllocation{ + Type: "team", + Allocated: uint64Ptr(1000000), + Used: uint64Ptr(2048), + UserWithinTeamSpaceAllocated: uint64Ptr(500000), + UserWithinTeamSpaceUsedCached: uint64Ptr(1024), + UserWithinTeamSpaceLimitType: "fixed", + }, + }), + }, nil), + "get": newJSONOperationOutput(getCommandInput{Source: "/Reports/old.pdf", Target: "old.pdf", Recursive: false, Stdout: false}, []jsonOperationResult{ + newJSONOperationResult(getStatusDownloaded, getKindFile, getResultInput{Source: "/Reports/old.pdf", Target: "old.pdf"}, file), + }, nil), + "ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ + newJSONOperationResult(lsJSONStatusListed, file.Type, nil, file), + }, nil), + "mkdir": newJSONOperationOutput(mkdirInput{Path: "/Reports/new", Parents: true}, []jsonOperationResult{ + newJSONOperationResult(mkdirStatusCreated, mkdirKindFolder, mkdirInput{Path: "/Reports/new", Parents: true}, sampleJSONFolderMetadata("/Reports/new")), + }, nil), + "mv": newJSONOperationOutput(nil, []jsonOperationResult{ + newJSONOperationResult("", "", relocationInput{FromPath: "/Reports/copy.pdf", ToPath: "/Reports/moved.pdf"}, sampleJSONFileMetadata("/Reports/moved.pdf")), + }, nil), + "put": newJSONOperationOutput(putCommandInput{Source: "README.md", Target: "/README.md", Recursive: true, IfExists: putIfExistsOverwrite, Stdin: false}, []jsonOperationResult{ + newJSONOperationResult(putStatusUploaded, putKindFile, putResultInput{Source: "README.md", Target: "/README.md"}, sampleJSONFileMetadata("/README.md")), + }, []jsonWarning{{Code: jsonWarningCodeSkippedSymlink, Message: "skipped symlink", Path: "docs/link"}}), + "restore": newJSONOperationOutput(restoreInput{Path: "/Reports/old.pdf", Revision: "015f"}, []jsonOperationResult{ + newJSONOperationResult(restoreStatusRestored, restoreKindFile, restoreInput{Path: "/Reports/old.pdf", Revision: "015f"}, file), + }, nil), + "revs": newJSONOperationOutput(revsInput{Path: "/Reports/old.pdf", Long: true, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ + newJSONOperationResult(revsJSONStatusRevision, file.Type, nil, file), + }, nil), + "rm": newJSONOperationOutput(nil, []jsonOperationResult{ + newJSONOperationResult("", "", removeInput{Path: "/Reports/old.pdf", Permanent: false, Recursive: false, Force: false}, file), + }, nil), + "search": newJSONOperationOutput(searchInput{Query: "report", Path: "/Reports", Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ + newJSONOperationResult(searchJSONStatusFound, folder.Type, nil, folder), + }, nil), + "share list folder": newJSONOperationOutput(shareFolderListInput{}, []jsonOperationResult{ + newJSONOperationResult(shareFolderJSONStatusListed, shareFolderJSONKindFolder, nil, sampleShareFolderJSONMetadata()), + }, nil), + "share list link": newJSONOperationOutput(shareLinkListInput{Path: "/Reports/old.pdf", DirectOnly: true}, []jsonOperationResult{ + shareLinkJSONOperationResult(shareLinkJSONStatusListed, sharedLink), + }, []jsonWarning{{Code: jsonWarningCodeDeprecatedCommand, Message: "use `dbxcli share-link list` instead"}}), + "share-link create": newJSONOperationOutput(shareLinkCreateInput{Path: "/Reports/old.pdf", Access: "viewer", Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true}, []jsonOperationResult{ + shareLinkJSONOperationResult(shareLinkJSONStatusCreated, sharedLink), + }, nil), + "share-link download": newJSONOperationOutput(shareLinkDownloadInput{URL: sharedLink.URL, Target: "old.pdf", Path: "/old.pdf", Recursive: false, Password: true}, []jsonOperationResult{ + newJSONOperationResult(shareLinkJSONStatusDownloaded, sharedLink.Type, nil, shareLinkDownloadResult{Target: "old.pdf", Link: sharedLink}), + }, nil), + "share-link info": newJSONOperationOutput(shareLinkInfoInput{URL: sharedLink.URL, Path: "/old.pdf", Password: true}, []jsonOperationResult{ + shareLinkJSONOperationResult(shareLinkJSONStatusFound, sharedLink), + }, nil), + "share-link list": newJSONOperationOutput(shareLinkListInput{Path: "/Reports/old.pdf", DirectOnly: true}, []jsonOperationResult{ + shareLinkJSONOperationResult(shareLinkJSONStatusListed, sharedLink), + }, nil), + "share-link revoke": newJSONOperationOutput(shareLinkRevokeInput{Path: "/Reports/old.pdf"}, []jsonOperationResult{ + newJSONOperationResult(shareLinkJSONStatusRevoked, sharedLink.Type, nil, shareLinkRevokeResult{URL: sharedLink.URL, Link: &sharedLink}), + }, nil), + "share-link update": newJSONOperationOutput(shareLinkUpdateInput{URL: sharedLink.URL, Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true, RemovePassword: false}, []jsonOperationResult{ + shareLinkJSONOperationResult(shareLinkJSONStatusUpdated, sharedLink), + }, nil), + "team add-member": newJSONOperationOutput(teamMemberAddInput{Email: "ada@example.com", FirstName: "Ada", LastName: "Lovelace"}, []jsonOperationResult{ + newJSONOperationResult(teamJSONStatusAdded, teamJSONKindTeamMember, teamMemberAddInput{Email: "ada@example.com", FirstName: "Ada", LastName: "Lovelace"}, teamMemberMutationJSON{ + Type: teamJSONTypeMemberAdd, + Tag: "complete", + Results: []teamMemberAddItemJSON{{ + Tag: "success", + Email: "ada@example.com", + Member: &teamMember, + }}, + }), + }, nil), + "team info": newJSONOperationOutput(teamInfoInput{}, []jsonOperationResult{ + newJSONOperationResult(teamJSONStatusFound, teamJSONKindTeam, teamInfoInput{}, teamInfoJSON{Type: teamJSONKindTeam, Name: "Engineering", TeamID: "team-id", NumLicensedUsers: 10, NumProvisionedUsers: 8}), + }, nil), + "team list-groups": newJSONOperationOutput(teamInfoInput{}, []jsonOperationResult{ + newJSONOperationResult(teamJSONStatusListed, teamJSONKindTeamGroup, nil, teamGroupJSON{Type: teamJSONKindTeamGroup, GroupName: "Developers", GroupID: "g:dev", GroupExternalID: "external-dev", MemberCount: 3, GroupManagementType: "company_managed"}), + }, nil), + "team list-members": newJSONOperationOutput(teamInfoInput{}, []jsonOperationResult{ + newJSONOperationResult(teamJSONStatusListed, teamJSONKindTeamMember, nil, teamMember), + }, nil), + "team remove-member": newJSONOperationOutput(teamMemberRemoveInput{Email: "ada@example.com"}, []jsonOperationResult{ + newJSONOperationResult(teamJSONStatusRemoved, teamJSONKindTeamMember, teamMemberRemoveInput{Email: "ada@example.com"}, teamMemberMutationJSON{Type: teamJSONTypeMemberRemove, Tag: "complete", AsyncJobID: "async-job-id"}), + }, nil), + "version": newJSONOperationOutput(versionInput{}, []jsonOperationResult{ + newJSONOperationResult("", versionKindVersion, versionInput{}, versionOutput{Version: "1.2.3", SDKVersion: "sdk-version", SpecVersion: "spec-version"}), + }, nil), + } +} + +func sampleJSONAccount() jsonAccount { + return jsonAccount{ + Type: "full", + AccountID: "dbid:account", + Name: &jsonAccountName{GivenName: "Ada", Surname: "Lovelace", FamiliarName: "Ada", DisplayName: "Ada Lovelace", AbbreviatedName: "AL"}, + Email: "ada@example.com", + EmailVerified: true, + Disabled: false, + ProfilePhotoURL: "https://example.com/profile.jpg", + Locale: "en", + ReferralLink: "https://example.com/referral", + IsPaired: boolPtr(false), + AccountType: "basic", + IsTeammate: boolPtr(true), + TeamMemberID: "dbmid:team-member", + Team: &jsonAccountTeam{ID: "team-id", Name: "Engineering", MemberID: "dbmid:team-member"}, + } +} + +func sampleJSONFileMetadata(path string) jsonMetadata { + return jsonMetadata{ + Type: "file", + PathDisplay: path, + PathLower: strings.ToLower(path), + ID: "id:file", + Rev: "015f", + Size: uint64Ptr(123), + ServerModified: jsonContractStringPtr("2026-06-25T12:00:00Z"), + ClientModified: jsonContractStringPtr("2026-06-25T11:00:00Z"), + } +} + +func sampleJSONFolderMetadata(path string) jsonMetadata { + return jsonMetadata{ + Type: "folder", + PathDisplay: path, + PathLower: strings.ToLower(path), + ID: "id:folder", + } +} + +func sampleShareFolderJSONMetadata() shareFolderJSONMetadata { + return shareFolderJSONMetadata{ + Type: shareFolderJSONKindFolder, + Name: "Reports", + PathLower: "/reports", + SharedFolderID: "sfid:reports", + PreviewURL: "https://www.dropbox.com/preview", + AccessType: "owner", + IsInsideTeamFolder: false, + IsTeamFolder: true, + OwnerDisplayNames: []string{"Ada Lovelace"}, + ParentSharedFolderID: "sfid:parent", + ParentFolderName: "Parent", + TimeInvited: jsonContractStringPtr("2026-06-25T10:00:00Z"), + AccessInheritance: "inherit", + } +} + +func sampleShareLinkJSONMetadata() shareLinkJSONMetadata { + return shareLinkJSONMetadata{ + Type: "file", + URL: "https://www.dropbox.com/s/example/old.pdf", + Name: "old.pdf", + PathLower: "/reports/old.pdf", + ID: "id:shared-file", + Expires: jsonContractStringPtr("2026-07-01T00:00:00Z"), + Rev: "015f", + Size: uint64Ptr(123), + ServerModified: jsonContractStringPtr("2026-06-25T12:00:00Z"), + ClientModified: jsonContractStringPtr("2026-06-25T11:00:00Z"), + Permissions: &shareLinkJSONPermissions{ + ResolvedVisibility: "public", + RequestedVisibility: "public", + EffectiveAudience: "public", + AccessLevel: "viewer", + CanRevoke: true, + AllowDownload: true, + CanSetExpiry: true, + CanRemoveExpiry: true, + CanAllowDownload: true, + CanDisallowDownload: true, + AllowComments: false, + CanSetPassword: true, + CanRemovePassword: true, + RequirePassword: true, + CanUseExtendedSharingControls: true, + }, + } +} + +func sampleTeamMemberJSON() teamMemberJSON { + return teamMemberJSON{ + Type: teamJSONKindTeamMember, + TeamMemberID: "dbmid:team-member", + ExternalID: "external-member", + AccountID: "dbid:account", + Email: "ada@example.com", + EmailVerified: true, + Status: "active", + Name: &jsonAccountName{GivenName: "Ada", Surname: "Lovelace", FamiliarName: "Ada", DisplayName: "Ada Lovelace", AbbreviatedName: "AL"}, + Role: "member_only", + Groups: []string{"g:dev"}, + MemberFolderID: "ns:member-folder", + MembershipType: "full", + InvitedOn: jsonContractStringPtr("2026-06-24T12:00:00Z"), + JoinedOn: jsonContractStringPtr("2026-06-25T12:00:00Z"), + SuspendedOn: jsonContractStringPtr("2026-06-26T12:00:00Z"), + PersistentID: "persistent-id", + IsDirectoryRestricted: true, + ProfilePhotoURL: "https://example.com/member.jpg", + } +} + +func jsonContractStringPtr(value string) *string { + return &value +} + +func jsonContractDefinitions() map[string][]string { + return normalizeStringSliceMap(map[string][]string{ + "account": jsonFieldNames[jsonAccount](), + "account_input": jsonFieldNames[accountInput](), + "account_name": jsonFieldNames[jsonAccountName](), + "account_team": jsonFieldNames[jsonAccountTeam](), + "du_allocation": jsonFieldNames[duAllocation](), + "du_output": jsonFieldNames[duOutput](), + "empty": {}, + "get_input": jsonFieldNames[getCommandInput](), + "get_result_input": jsonFieldNames[getResultInput](), + "ls_input": jsonFieldNames[lsInput](), + "metadata": jsonFieldNames[jsonMetadata](), + "mkdir_input": jsonFieldNames[mkdirInput](), + "operation_output": jsonFieldNames[jsonOperationOutput](), + "operation_result": jsonFieldNames[jsonOperationResult](), + "put_input": jsonFieldNames[putCommandInput](), + "put_result_input": jsonFieldNames[putResultInput](), + "relocation_input": jsonFieldNames[relocationInput](), + "remove_input": jsonFieldNames[removeInput](), + "restore_input": jsonFieldNames[restoreInput](), + "revs_input": jsonFieldNames[revsInput](), + "search_input": jsonFieldNames[searchInput](), + "share_folder": jsonFieldNames[shareFolderJSONMetadata](), + "share_link_create_input": jsonFieldNames[shareLinkCreateInput](), + "share_link_download_input": jsonFieldNames[shareLinkDownloadInput](), + "share_link_download_result": jsonFieldNames[shareLinkDownloadResult](), + "share_link_info_input": jsonFieldNames[shareLinkInfoInput](), + "share_link_list_input": jsonFieldNames[shareLinkListInput](), + "share_link_metadata": jsonFieldNames[shareLinkJSONMetadata](), + "share_link_permissions": jsonFieldNames[shareLinkJSONPermissions](), + "share_link_revoke_input": jsonFieldNames[shareLinkRevokeInput](), + "share_link_revoke_result": jsonFieldNames[shareLinkRevokeResult](), + "share_link_update_input": jsonFieldNames[shareLinkUpdateInput](), + "team_group": jsonFieldNames[teamGroupJSON](), + "team_info": jsonFieldNames[teamInfoJSON](), + "team_member": jsonFieldNames[teamMemberJSON](), + "team_member_add_input": jsonFieldNames[teamMemberAddInput](), + "team_member_add_item": jsonFieldNames[teamMemberAddItemJSON](), + "team_member_mutation": jsonFieldNames[teamMemberMutationJSON](), + "team_member_remove_input": jsonFieldNames[teamMemberRemoveInput](), + "version": jsonFieldNames[versionOutput](), + }) +} + +func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { + return map[string]jsonGoldenCommandSchema{ + "account": operationSchema("account_input", schemaRef("account_input"), "account", nil, []string{accountKindAccount}, nil), + "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", nil, nil, nil), + "du": operationSchema("empty", schemaRef("empty"), "du_output", nil, []string{duKindSpaceUsage}, nil), + "get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil), + "ls": operationSchema("ls_input", nil, "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil), + "mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting}, []string{mkdirKindFolder}, nil), + "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", nil, nil, nil), + "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}), + "restore": operationSchema("restore_input", schemaRef("restore_input"), "metadata", []string{restoreStatusRestored}, []string{restoreKindFile}, nil), + "revs": operationSchema("revs_input", nil, "metadata", []string{revsJSONStatusRevision}, []string{"file", "unknown"}, nil), + "rm": operationSchema("empty", schemaRef("remove_input"), "metadata", nil, nil, nil), + "search": operationSchema("search_input", nil, "metadata", []string{searchJSONStatusFound}, metadataKinds(), nil), + "share list folder": operationSchema("empty", nil, "share_folder", []string{shareFolderJSONStatusListed}, []string{shareFolderJSONKindFolder}, nil), + "share list link": operationSchema("share_link_list_input", nil, "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), []string{jsonWarningCodeDeprecatedCommand}), + "share-link create": operationSchema("share_link_create_input", nil, "share_link_metadata", []string{shareLinkJSONStatusCreated, shareLinkJSONStatusExisting}, shareLinkKinds(), nil), + "share-link download": operationSchema( + "share_link_download_input", + nil, + "share_link_download_result", + []string{shareLinkJSONStatusDownloaded}, + shareLinkKinds(), + nil, + ), + "share-link info": operationSchema("share_link_info_input", nil, "share_link_metadata", []string{shareLinkJSONStatusFound}, shareLinkKinds(), nil), + "share-link list": operationSchema("share_link_list_input", nil, "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), nil), + "share-link revoke": operationSchema("share_link_revoke_input", nil, "share_link_revoke_result", []string{shareLinkJSONStatusRevoked}, append(shareLinkKinds(), shareLinkJSONKindSharedLink), nil), + "share-link update": operationSchema("share_link_update_input", nil, "share_link_metadata", []string{shareLinkJSONStatusUpdated}, shareLinkKinds(), nil), + "team add-member": operationSchema("team_member_add_input", schemaRef("team_member_add_input"), "team_member_mutation", []string{teamJSONStatusAdded, teamJSONStatusCompleted, teamJSONStatusStarted}, []string{teamJSONKindTeamMember}, nil), + "team info": operationSchema("empty", schemaRef("empty"), "team_info", []string{teamJSONStatusFound}, []string{teamJSONKindTeam}, nil), + "team list-groups": operationSchema("empty", nil, "team_group", []string{teamJSONStatusListed}, []string{teamJSONKindTeamGroup}, nil), + "team list-members": operationSchema("empty", nil, "team_member", []string{teamJSONStatusListed}, []string{teamJSONKindTeamMember}, nil), + "team remove-member": operationSchema("team_member_remove_input", schemaRef("team_member_remove_input"), "team_member_mutation", []string{teamJSONStatusCompleted, teamJSONStatusRemoved, teamJSONStatusStarted}, []string{teamJSONKindTeamMember}, nil), + "version": operationSchema("empty", schemaRef("empty"), "version", nil, []string{versionKindVersion}, nil), + } +} + +func operationSchema(input string, resultInput *string, result string, statuses, kinds, warnings []string) jsonGoldenCommandSchema { + return normalizeGoldenCommandSchema(jsonGoldenCommandSchema{ + TopLevel: "operation_output", + ResultWrapper: "operation_result", + Input: input, + ResultInput: resultInput, + Result: result, + Statuses: statuses, + Kinds: kinds, + Warnings: warnings, + }) +} + +func schemaRef(name string) *string { + return &name +} + +func metadataKinds() []string { + return []string{"deleted", "file", "folder", "unknown"} +} + +func shareLinkKinds() []string { + return []string{"file", "folder", "link"} +} + +func jsonFieldNames[T any]() []string { + typ := reflect.TypeOf((*T)(nil)).Elem() + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if typ.Kind() != reflect.Struct { + return nil + } + + var names []string + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.PkgPath != "" { + continue + } + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + names = append(names, name) + } + sort.Strings(names) + return names +} + +func assertStringSliceMapEqual(t *testing.T, label string, got, want map[string][]string) { + t.Helper() + + got = normalizeStringSliceMap(got) + want = normalizeStringSliceMap(want) + if reflect.DeepEqual(got, want) { + return + } + + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("%s = %s, want %s", label, gotJSON, wantJSON) +} + +func assertGoldenCommandSchemaEqual(t *testing.T, command string, got, want jsonGoldenCommandSchema) { + t.Helper() + + got = normalizeGoldenCommandSchema(got) + want = normalizeGoldenCommandSchema(want) + if reflect.DeepEqual(got, want) { + return + } + + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Errorf("golden schema for %q = %s, want %s", command, gotJSON, wantJSON) +} + +func assertGoldenCommandSchemaReferences(t *testing.T, command string, schema jsonGoldenCommandSchema, definitions map[string][]string) { + t.Helper() + + refs := []string{schema.TopLevel, schema.ResultWrapper, schema.Input, schema.Result} + if schema.ResultInput != nil { + refs = append(refs, *schema.ResultInput) + } + for _, ref := range refs { + if _, ok := definitions[ref]; !ok { + t.Errorf("golden schema for %q references unknown definition %q", command, ref) + } + } +} + +func normalizeGoldenContract(contract jsonGoldenContract) jsonGoldenContract { + contract.Definitions = normalizeStringSliceMap(contract.Definitions) + commands := make(map[string]jsonGoldenCommandSchema, len(contract.Commands)) + for command, schema := range contract.Commands { + commands[command] = normalizeGoldenCommandSchema(schema) + } + contract.Commands = commands + return contract +} + +func normalizeGoldenCommandSchema(schema jsonGoldenCommandSchema) jsonGoldenCommandSchema { + schema.Statuses = sortedCopy(schema.Statuses) + schema.Kinds = sortedCopy(schema.Kinds) + schema.Warnings = sortedCopy(schema.Warnings) + return schema +} + +func normalizeStringSliceMap(values map[string][]string) map[string][]string { + normalized := make(map[string][]string, len(values)) + for key, value := range values { + normalized[key] = sortedCopy(value) + } + return normalized +} + +func sortedCopy(values []string) []string { + if values == nil { + return []string{} + } + copied := append([]string{}, values...) + sort.Strings(copied) + return copied } func structuredOutputCommandPaths(root *cobra.Command) []string { diff --git a/cmd/output.go b/cmd/output.go index 224f609..4e4b9f4 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -3,9 +3,12 @@ package cmd import ( "errors" "fmt" + "reflect" "strings" "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + dropboxauth "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" "github.com/spf13/cobra" ) @@ -14,8 +17,16 @@ const ( structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput" jsonErrorCodeCommandFailed = "command_failed" + jsonErrorCodeAppKeyRequired = "app_key_required" + jsonErrorCodeAuthExchangeFailed = "auth_exchange_failed" + jsonErrorCodeAuthRefreshFailed = "auth_refresh_failed" + jsonErrorCodeAuthRequired = "auth_required" + jsonErrorCodeDropboxAPIError = "dropbox_api_error" jsonErrorCodeInvalidArguments = "invalid_arguments" + jsonErrorCodeNotFound = "not_found" jsonErrorCodePathConflict = "path_conflict" + jsonErrorCodePermissionDenied = "permission_denied" + jsonErrorCodeRateLimited = "rate_limited" jsonErrorCodeStructuredOutputUnsupported = "structured_output_unsupported" jsonErrorCodeUnknownCommand = "unknown_command" jsonErrorCodeUnknownFlag = "unknown_flag" @@ -66,6 +77,30 @@ func pathConflictErrorf(format string, args ...any) error { return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...)) } +func authRequiredErrorf(format string, args ...any) error { + return newCodedError(jsonErrorCodeAuthRequired, fmt.Errorf(format, args...)) +} + +func appKeyRequiredError(message string) error { + return newCodedError(jsonErrorCodeAppKeyRequired, errors.New(message)) +} + +func appKeyRequiredErrorf(format string, args ...any) error { + return newCodedError(jsonErrorCodeAppKeyRequired, fmt.Errorf(format, args...)) +} + +func authExchangeFailedError(message string) error { + return newCodedError(jsonErrorCodeAuthExchangeFailed, errors.New(message)) +} + +func authExchangeFailedErrorf(format string, args ...any) error { + return newCodedError(jsonErrorCodeAuthExchangeFailed, fmt.Errorf(format, args...)) +} + +func authRefreshFailedErrorf(format string, args ...any) error { + return newCodedError(jsonErrorCodeAuthRefreshFailed, fmt.Errorf(format, args...)) +} + func unsupportedOutputFormatErrorf(format string, args ...any) error { return newCodedError(jsonErrorCodeUnsupportedOutputFormat, fmt.Errorf(format, args...)) } @@ -230,6 +265,9 @@ func jsonErrorCode(err error) string { if errors.Is(err, output.ErrStructuredOutputUnsupported) { return jsonErrorCodeStructuredOutputUnsupported } + if code := dropboxAPIJSONErrorCode(err); code != "" { + return code + } message := err.Error() switch { @@ -241,3 +279,183 @@ func jsonErrorCode(err error) string { return jsonErrorCodeCommandFailed } } + +func dropboxAPIJSONErrorCode(err error) string { + var rateLimitErr dropboxauth.RateLimitAPIError + var rateLimitErrPtr *dropboxauth.RateLimitAPIError + if errors.As(err, &rateLimitErr) || errors.As(err, &rateLimitErrPtr) { + return jsonErrorCodeRateLimited + } + + var authErr dropboxauth.AuthAPIError + var authErrPtr *dropboxauth.AuthAPIError + if errors.As(err, &authErr) { + return dropboxAuthAPIErrorCode(authErr.AuthError) + } + if errors.As(err, &authErrPtr) { + if authErrPtr == nil { + return jsonErrorCodeDropboxAPIError + } + return dropboxAuthAPIErrorCode(authErrPtr.AuthError) + } + + var accessErr dropboxauth.AccessAPIError + var accessErrPtr *dropboxauth.AccessAPIError + if errors.As(err, &accessErr) || errors.As(err, &accessErrPtr) { + return jsonErrorCodePermissionDenied + } + + if summary, ok := dropboxAPIErrorSummary(err); ok { + return dropboxAPIMessageErrorCode(summary) + } + if summary, ok := dropboxAPISummaryFromMessage(err.Error()); ok { + return dropboxAPIMessageErrorCode(summary) + } + return "" +} + +func dropboxAuthAPIErrorCode(authErr *dropboxauth.AuthError) string { + if authErr == nil { + return jsonErrorCodeDropboxAPIError + } + switch authErr.Tag { + case dropboxauth.AuthErrorInvalidAccessToken, dropboxauth.AuthErrorExpiredAccessToken: + return jsonErrorCodeAuthRequired + case dropboxauth.AuthErrorInvalidSelectUser, + dropboxauth.AuthErrorInvalidSelectAdmin, + dropboxauth.AuthErrorUserSuspended, + dropboxauth.AuthErrorMissingScope, + dropboxauth.AuthErrorRouteAccessDenied: + return jsonErrorCodePermissionDenied + default: + return jsonErrorCodeDropboxAPIError + } +} + +func dropboxAPIErrorSummary(err error) (string, bool) { + for err != nil { + if summary, ok := dropboxAPIErrorSummaryValue(err); ok { + return summary, true + } + err = errors.Unwrap(err) + } + return "", false +} + +func dropboxAPIErrorSummaryValue(err error) (string, bool) { + if err == nil { + return "", false + } + value := reflect.ValueOf(err) + if !value.IsValid() { + return "", false + } + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return "", false + } + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return "", false + } + + typ := value.Type() + if typ == reflect.TypeOf(dropbox.APIError{}) { + return err.Error(), true + } + if !strings.HasPrefix(typ.PkgPath(), "github.com/dropbox/dropbox-sdk-go-unofficial/") { + return "", false + } + + field := value.FieldByName("APIError") + if field.IsValid() && field.CanInterface() { + if apiErr, ok := field.Interface().(dropbox.APIError); ok { + return apiErr.Error(), true + } + } + if strings.HasSuffix(typ.Name(), "APIError") { + return err.Error(), true + } + return "", false +} + +func dropboxAPISummaryFromMessage(message string) (string, bool) { + lower := strings.ToLower(message) + if strings.Contains(lower, "error in call to api function") { + return message, true + } + + trimmed := strings.TrimSpace(message) + if isDropboxAPISummary(trimmed) { + return trimmed, true + } + if idx := strings.LastIndex(trimmed, ": "); idx >= 0 { + tail := strings.TrimSpace(trimmed[idx+2:]) + if isDropboxAPISummary(tail) { + return tail, true + } + } + return "", false +} + +func isDropboxAPISummary(message string) bool { + if message == "" || strings.ContainsAny(message, " \t\r\n\"") || !strings.Contains(message, "/") { + return false + } + segments := strings.Split(message, "/") + validSegments := 0 + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." || strings.HasPrefix(segment, "...") { + continue + } + if !isDropboxAPISummarySegment(segment) { + return false + } + validSegments++ + } + return validSegments >= 1 +} + +func isDropboxAPISummarySegment(segment string) bool { + for _, r := range segment { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' { + continue + } + return false + } + return true +} + +func dropboxAPIMessageErrorCode(message string) string { + lower := strings.ToLower(message) + switch { + case strings.Contains(lower, "invalid_access_token") || + strings.Contains(lower, "expired_access_token"): + return jsonErrorCodeAuthRequired + case strings.Contains(lower, "too_many_requests") || + strings.Contains(lower, "rate_limit") || + strings.Contains(lower, "rate_limited"): + return jsonErrorCodeRateLimited + case strings.Contains(lower, "path/conflict") || + strings.Contains(lower, "to/conflict") || + strings.Contains(lower, "from/conflict"): + return jsonErrorCodePathConflict + case strings.Contains(lower, "not_found") || + strings.Contains(lower, "not found"): + return jsonErrorCodeNotFound + case strings.Contains(lower, "no_permission") || + strings.Contains(lower, "access_denied") || + strings.Contains(lower, "insufficient_permissions") || + strings.Contains(lower, "missing_scope") || + strings.Contains(lower, "route_access_denied") || + strings.Contains(lower, "user_suspended") || + strings.Contains(lower, "invalid_select_user") || + strings.Contains(lower, "invalid_select_admin"): + return jsonErrorCodePermissionDenied + case strings.Contains(lower, "error in call to api function"): + return jsonErrorCodeDropboxAPIError + default: + return "" + } +} diff --git a/cmd/output_test.go b/cmd/output_test.go index 86f30cd..4303d30 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -10,6 +10,9 @@ import ( "testing" "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + dropboxauth "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) @@ -620,6 +623,123 @@ func TestJSONErrorCodeUsesCodedErrors(t *testing.T) { err: unsupportedOutputFormatErrorf("unsupported output format %q: use text or json", "yaml"), want: jsonErrorCodeUnsupportedOutputFormat, }, + { + name: "auth required", + err: authRequiredErrorf("no saved Dropbox credentials"), + want: jsonErrorCodeAuthRequired, + }, + { + name: "app key required", + err: appKeyRequiredError("Dropbox app key is required"), + want: jsonErrorCodeAppKeyRequired, + }, + { + name: "auth exchange failed", + err: authExchangeFailedError("authorization did not return an access token"), + want: jsonErrorCodeAuthExchangeFailed, + }, + { + name: "auth refresh failed", + err: authRefreshFailedErrorf("refresh saved Dropbox credentials: failed"), + want: jsonErrorCodeAuthRefreshFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := jsonErrorCode(tt.err); got != tt.want { + t.Fatalf("jsonErrorCode = %q, want %q", got, tt.want) + } + }) + } +} + +func TestJSONErrorCodeClassifiesDropboxAPIErrors(t *testing.T) { + expiredAuth := dropboxauth.AuthAPIError{AuthError: &dropboxauth.AuthError{}} + expiredAuth.AuthError.Tag = dropboxauth.AuthErrorExpiredAccessToken + missingScope := dropboxauth.AuthAPIError{AuthError: &dropboxauth.AuthError{}} + missingScope.AuthError.Tag = dropboxauth.AuthErrorMissingScope + + tests := []struct { + name string + err error + want string + }{ + { + name: "rate limit api error", + err: dropboxauth.RateLimitAPIError{}, + want: jsonErrorCodeRateLimited, + }, + { + name: "expired access token", + err: expiredAuth, + want: jsonErrorCodeAuthRequired, + }, + { + name: "missing scope", + err: missingScope, + want: jsonErrorCodePermissionDenied, + }, + { + name: "access api error", + err: dropboxauth.AccessAPIError{}, + want: jsonErrorCodePermissionDenied, + }, + { + name: "typed files api error", + err: files.GetMetadataAPIError{APIError: dropbox.APIError{ErrorSummary: "path/not_found/"}}, + want: jsonErrorCodeNotFound, + }, + { + name: "wrapped typed files api error", + err: fmt.Errorf("get metadata: %w", files.GetMetadataAPIError{APIError: dropbox.APIError{ErrorSummary: "path/not_found/"}}), + want: jsonErrorCodeNotFound, + }, + { + name: "typed path conflict api error", + err: files.CreateFolderV2APIError{APIError: dropbox.APIError{ErrorSummary: "path/conflict/folder/"}}, + want: jsonErrorCodePathConflict, + }, + { + name: "relocation conflict summary", + err: errors.New(`Error in call to API function "files/move_v2": to/conflict/file/.`), + want: jsonErrorCodePathConflict, + }, + { + name: "not found summary", + err: errors.New(`Error in call to API function "files/get_metadata": path/not_found/.`), + want: jsonErrorCodeNotFound, + }, + { + name: "exact api summary", + err: errors.New("path/not_found/"), + want: jsonErrorCodeNotFound, + }, + { + name: "exact auth summary", + err: errors.New("invalid_access_token/"), + want: jsonErrorCodeAuthRequired, + }, + { + name: "wrapped api summary text", + err: errors.New("get metadata for /missing: path/not_found/"), + want: jsonErrorCodeNotFound, + }, + { + name: "permission summary", + err: errors.New(`Error in call to API function "files/list_folder": path/no_permission/.`), + want: jsonErrorCodePermissionDenied, + }, + { + name: "rate limit summary", + err: errors.New(`Error in call to API function "files/upload": too_many_requests/...`), + want: jsonErrorCodeRateLimited, + }, + { + name: "generic api function error", + err: errors.New(`Error in call to API function "sharing/create_shared_link_with_settings": shared_link_already_exists/.`), + want: jsonErrorCodeDropboxAPIError, + }, } for _, tt := range tests { @@ -636,6 +756,9 @@ func TestJSONErrorCodeDoesNotClassifyPlainValidationStrings(t *testing.T) { errors.New("path exists and is not a folder: /file"), errors.New("Dropbox API requires team admin permissions"), errors.New("`account` accepts an optional `id` argument"), + errors.New("local cache not found"), + errors.New("config missing_scope marker"), + errors.New("local path/not_found/ marker"), } { if got := jsonErrorCode(err); got != jsonErrorCodeCommandFailed { t.Fatalf("jsonErrorCode(%q) = %q, want %q", err.Error(), got, jsonErrorCodeCommandFailed) diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json new file mode 100644 index 0000000..ec87a4e --- /dev/null +++ b/cmd/testdata/json_contract/success_outputs.json @@ -0,0 +1,28 @@ +{ + "account": {"input":{"account_id":"dbid:lookup"},"results":[{"kind":"account","input":{"account_id":"dbid:lookup"},"result":{"type":"full","account_id":"dbid:account","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"email":"ada@example.com","email_verified":true,"disabled":false,"profile_photo_url":"https://example.com/profile.jpg","locale":"en","referral_link":"https://example.com/referral","is_paired":false,"account_type":"basic","is_teammate":true,"team_member_id":"dbmid:team-member","team":{"id":"team-id","name":"Engineering","member_id":"dbmid:team-member"}}}],"warnings":[]}, + "cp": {"input":{},"results":[{"input":{"from_path":"/Reports/old.pdf","to_path":"/Reports/copy.pdf"},"result":{"type":"file","path_display":"/Reports/copy.pdf","path_lower":"/reports/copy.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "du": {"input":{},"results":[{"kind":"space_usage","input":{},"result":{"used":2048,"allocation":{"type":"team","allocated":1000000,"used":2048,"user_within_team_space_allocated":500000,"user_within_team_space_used_cached":1024,"user_within_team_space_limit_type":"fixed"}}}],"warnings":[]}, + "get": {"input":{"source":"/Reports/old.pdf","target":"old.pdf","recursive":false,"stdout":false},"results":[{"status":"downloaded","kind":"file","input":{"source":"/Reports/old.pdf","target":"old.pdf"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "ls": {"input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "mkdir": {"input":{"path":"/Reports/new","parents":true},"results":[{"status":"created","kind":"folder","input":{"path":"/Reports/new","parents":true},"result":{"type":"folder","path_display":"/Reports/new","path_lower":"/reports/new","id":"id:folder"}}],"warnings":[]}, + "mv": {"input":{},"results":[{"input":{"from_path":"/Reports/copy.pdf","to_path":"/Reports/moved.pdf"},"result":{"type":"file","path_display":"/Reports/moved.pdf","path_lower":"/reports/moved.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "put": {"input":{"source":"README.md","target":"/README.md","recursive":true,"if_exists":"overwrite","stdin":false},"results":[{"status":"uploaded","kind":"file","input":{"source":"README.md","target":"/README.md"},"result":{"type":"file","path_display":"/README.md","path_lower":"/readme.md","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[{"code":"skipped_symlink","message":"skipped symlink","path":"docs/link"}]}, + "restore": {"input":{"path":"/Reports/old.pdf","revision":"015f"},"results":[{"status":"restored","kind":"file","input":{"path":"/Reports/old.pdf","revision":"015f"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "revs": {"input":{"path":"/Reports/old.pdf","long":true,"time":"server","time_format":"2006-01-02"},"results":[{"status":"revision","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "rm": {"input":{},"results":[{"input":{"path":"/Reports/old.pdf","permanent":false,"recursive":false,"force":false},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "search": {"input":{"query":"report","path":"/Reports","long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"}}],"warnings":[]}, + "share list folder": {"input":{},"results":[{"status":"listed","kind":"shared_folder","result":{"type":"shared_folder","name":"Reports","path_lower":"/reports","shared_folder_id":"sfid:reports","preview_url":"https://www.dropbox.com/preview","access_type":"owner","is_inside_team_folder":false,"is_team_folder":true,"owner_display_names":["Ada Lovelace"],"parent_shared_folder_id":"sfid:parent","parent_folder_name":"Parent","time_invited":"2026-06-25T10:00:00Z","access_inheritance":"inherit"}}],"warnings":[]}, + "share list link": {"input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[{"code":"deprecated_command","message":"use `dbxcli share-link list` instead"}]}, + "share-link create": {"input":{"path":"/Reports/old.pdf","access":"viewer","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, + "share-link download": {"input":{"url":"https://www.dropbox.com/s/example/old.pdf","target":"old.pdf","path":"/old.pdf","password":true},"results":[{"status":"downloaded","kind":"file","result":{"target":"old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}}],"warnings":[]}, + "share-link info": {"input":{"url":"https://www.dropbox.com/s/example/old.pdf","path":"/old.pdf","password":true},"results":[{"status":"found","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, + "share-link list": {"input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, + "share-link revoke": {"input":{"path":"/Reports/old.pdf"},"results":[{"status":"revoked","kind":"file","result":{"url":"https://www.dropbox.com/s/example/old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}}],"warnings":[]}, + "share-link update": {"input":{"url":"https://www.dropbox.com/s/example/old.pdf","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"updated","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, + "team add-member": {"input":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace"},"results":[{"status":"added","kind":"team_member","input":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace"},"result":{"type":"team_member_add","tag":"complete","results":[{"tag":"success","email":"ada@example.com","member":{"type":"team_member","team_member_id":"dbmid:team-member","external_id":"external-member","account_id":"dbid:account","email":"ada@example.com","email_verified":true,"status":"active","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"role":"member_only","groups":["g:dev"],"member_folder_id":"ns:member-folder","membership_type":"full","invited_on":"2026-06-24T12:00:00Z","joined_on":"2026-06-25T12:00:00Z","suspended_on":"2026-06-26T12:00:00Z","persistent_id":"persistent-id","is_directory_restricted":true,"profile_photo_url":"https://example.com/member.jpg"}}]}}],"warnings":[]}, + "team info": {"input":{},"results":[{"status":"found","kind":"team","input":{},"result":{"type":"team","name":"Engineering","team_id":"team-id","num_licensed_users":10,"num_provisioned_users":8}}],"warnings":[]}, + "team list-groups": {"input":{},"results":[{"status":"listed","kind":"team_group","result":{"type":"team_group","group_name":"Developers","group_id":"g:dev","group_external_id":"external-dev","member_count":3,"group_management_type":"company_managed"}}],"warnings":[]}, + "team list-members": {"input":{},"results":[{"status":"listed","kind":"team_member","result":{"type":"team_member","team_member_id":"dbmid:team-member","external_id":"external-member","account_id":"dbid:account","email":"ada@example.com","email_verified":true,"status":"active","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"role":"member_only","groups":["g:dev"],"member_folder_id":"ns:member-folder","membership_type":"full","invited_on":"2026-06-24T12:00:00Z","joined_on":"2026-06-25T12:00:00Z","suspended_on":"2026-06-26T12:00:00Z","persistent_id":"persistent-id","is_directory_restricted":true,"profile_photo_url":"https://example.com/member.jpg"}}],"warnings":[]}, + "team remove-member": {"input":{"email":"ada@example.com"},"results":[{"status":"removed","kind":"team_member","input":{"email":"ada@example.com"},"result":{"type":"team_member_remove","tag":"complete","async_job_id":"async-job-id"}}],"warnings":[]}, + "version": {"input":{},"results":[{"kind":"version","input":{},"result":{"version":"1.2.3","sdk_version":"sdk-version","spec_version":"spec-version"}}],"warnings":[]} +} diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json new file mode 100644 index 0000000..cba66fd --- /dev/null +++ b/cmd/testdata/json_contract/success_schemas.json @@ -0,0 +1,674 @@ +{ + "definitions": { + "account": [ + "account_id", + "account_type", + "disabled", + "email", + "email_verified", + "is_paired", + "is_teammate", + "locale", + "name", + "profile_photo_url", + "referral_link", + "team", + "team_member_id", + "type" + ], + "account_input": [ + "account_id" + ], + "account_name": [ + "abbreviated_name", + "display_name", + "familiar_name", + "given_name", + "surname" + ], + "account_team": [ + "id", + "member_id", + "name" + ], + "du_allocation": [ + "allocated", + "type", + "used", + "user_within_team_space_allocated", + "user_within_team_space_limit_type", + "user_within_team_space_used_cached" + ], + "du_output": [ + "allocation", + "used" + ], + "empty": [], + "get_input": [ + "recursive", + "source", + "stdout", + "target" + ], + "get_result_input": [ + "source", + "target" + ], + "ls_input": [ + "include_deleted", + "long", + "only_deleted", + "path", + "recursive", + "reverse", + "sort", + "time", + "time_format" + ], + "metadata": [ + "client_modified", + "deleted", + "id", + "path_display", + "path_lower", + "rev", + "server_modified", + "size", + "type" + ], + "mkdir_input": [ + "parents", + "path" + ], + "operation_output": [ + "input", + "results", + "warnings" + ], + "operation_result": [ + "input", + "kind", + "result", + "status" + ], + "put_input": [ + "if_exists", + "recursive", + "source", + "stdin", + "target" + ], + "put_result_input": [ + "source", + "target" + ], + "relocation_input": [ + "from_path", + "to_path" + ], + "remove_input": [ + "force", + "path", + "permanent", + "recursive" + ], + "restore_input": [ + "path", + "revision" + ], + "revs_input": [ + "long", + "path", + "time", + "time_format" + ], + "search_input": [ + "long", + "path", + "query", + "reverse", + "sort", + "time", + "time_format" + ], + "share_folder": [ + "access_inheritance", + "access_type", + "is_inside_team_folder", + "is_team_folder", + "name", + "owner_display_names", + "parent_folder_name", + "parent_shared_folder_id", + "path_lower", + "preview_url", + "shared_folder_id", + "time_invited", + "type" + ], + "share_link_create_input": [ + "access", + "allow_download", + "audience", + "disallow_download", + "expires", + "password", + "path", + "remove_expiration" + ], + "share_link_download_input": [ + "password", + "path", + "recursive", + "target", + "url" + ], + "share_link_download_result": [ + "link", + "target" + ], + "share_link_info_input": [ + "password", + "path", + "url" + ], + "share_link_list_input": [ + "direct_only", + "path" + ], + "share_link_metadata": [ + "client_modified", + "expires", + "id", + "name", + "path_lower", + "permissions", + "rev", + "server_modified", + "size", + "type", + "url" + ], + "share_link_permissions": [ + "access_level", + "allow_comments", + "allow_download", + "can_allow_download", + "can_disallow_download", + "can_remove_expiry", + "can_remove_password", + "can_revoke", + "can_set_expiry", + "can_set_password", + "can_use_extended_sharing_controls", + "effective_audience", + "require_password", + "requested_visibility", + "resolved_visibility" + ], + "share_link_revoke_input": [ + "path", + "url" + ], + "share_link_revoke_result": [ + "link", + "url" + ], + "share_link_update_input": [ + "allow_download", + "audience", + "disallow_download", + "expires", + "password", + "remove_expiration", + "remove_password", + "url" + ], + "team_group": [ + "group_external_id", + "group_id", + "group_management_type", + "group_name", + "member_count", + "type" + ], + "team_info": [ + "name", + "num_licensed_users", + "num_provisioned_users", + "team_id", + "type" + ], + "team_member": [ + "account_id", + "email", + "email_verified", + "external_id", + "groups", + "invited_on", + "is_directory_restricted", + "joined_on", + "member_folder_id", + "membership_type", + "name", + "persistent_id", + "profile_photo_url", + "role", + "status", + "suspended_on", + "team_member_id", + "type" + ], + "team_member_add_input": [ + "email", + "first_name", + "last_name" + ], + "team_member_add_item": [ + "email", + "member", + "tag" + ], + "team_member_mutation": [ + "async_job_id", + "results", + "tag", + "type" + ], + "team_member_remove_input": [ + "email" + ], + "version": [ + "sdk_version", + "spec_version", + "version" + ] + }, + "commands": { + "account": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "account_input", + "result_input": "account_input", + "result": "account", + "statuses": [], + "kinds": [ + "account" + ], + "warnings": [] + }, + "cp": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "relocation_input", + "result": "metadata", + "statuses": [], + "kinds": [], + "warnings": [] + }, + "du": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "du_output", + "statuses": [], + "kinds": [ + "space_usage" + ], + "warnings": [] + }, + "get": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "get_input", + "result_input": "get_result_input", + "result": "metadata", + "statuses": [ + "created", + "downloaded", + "existing" + ], + "kinds": [ + "file", + "folder" + ], + "warnings": [] + }, + "ls": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "ls_input", + "result_input": null, + "result": "metadata", + "statuses": [ + "listed" + ], + "kinds": [ + "deleted", + "file", + "folder", + "unknown" + ], + "warnings": [] + }, + "mkdir": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "mkdir_input", + "result_input": "mkdir_input", + "result": "metadata", + "statuses": [ + "created", + "existing" + ], + "kinds": [ + "folder" + ], + "warnings": [] + }, + "mv": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "relocation_input", + "result": "metadata", + "statuses": [], + "kinds": [], + "warnings": [] + }, + "put": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "put_input", + "result_input": "put_result_input", + "result": "metadata", + "statuses": [ + "created", + "existing", + "skipped", + "uploaded" + ], + "kinds": [ + "file", + "folder" + ], + "warnings": [ + "skipped_symlink" + ] + }, + "restore": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "restore_input", + "result_input": "restore_input", + "result": "metadata", + "statuses": [ + "restored" + ], + "kinds": [ + "file" + ], + "warnings": [] + }, + "revs": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "revs_input", + "result_input": null, + "result": "metadata", + "statuses": [ + "revision" + ], + "kinds": [ + "file", + "unknown" + ], + "warnings": [] + }, + "rm": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "remove_input", + "result": "metadata", + "statuses": [], + "kinds": [], + "warnings": [] + }, + "search": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "search_input", + "result_input": null, + "result": "metadata", + "statuses": [ + "found" + ], + "kinds": [ + "deleted", + "file", + "folder", + "unknown" + ], + "warnings": [] + }, + "share list folder": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": null, + "result": "share_folder", + "statuses": [ + "listed" + ], + "kinds": [ + "shared_folder" + ], + "warnings": [] + }, + "share list link": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_list_input", + "result_input": null, + "result": "share_link_metadata", + "statuses": [ + "listed" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [ + "deprecated_command" + ] + }, + "share-link create": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_create_input", + "result_input": null, + "result": "share_link_metadata", + "statuses": [ + "created", + "existing" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link download": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_download_input", + "result_input": null, + "result": "share_link_download_result", + "statuses": [ + "downloaded" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link info": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_info_input", + "result_input": null, + "result": "share_link_metadata", + "statuses": [ + "found" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link list": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_list_input", + "result_input": null, + "result": "share_link_metadata", + "statuses": [ + "listed" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link revoke": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_revoke_input", + "result_input": null, + "result": "share_link_revoke_result", + "statuses": [ + "revoked" + ], + "kinds": [ + "file", + "folder", + "link", + "shared_link" + ], + "warnings": [] + }, + "share-link update": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_update_input", + "result_input": null, + "result": "share_link_metadata", + "statuses": [ + "updated" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "team add-member": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "team_member_add_input", + "result_input": "team_member_add_input", + "result": "team_member_mutation", + "statuses": [ + "added", + "completed", + "started" + ], + "kinds": [ + "team_member" + ], + "warnings": [] + }, + "team info": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "team_info", + "statuses": [ + "found" + ], + "kinds": [ + "team" + ], + "warnings": [] + }, + "team list-groups": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": null, + "result": "team_group", + "statuses": [ + "listed" + ], + "kinds": [ + "team_group" + ], + "warnings": [] + }, + "team list-members": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": null, + "result": "team_member", + "statuses": [ + "listed" + ], + "kinds": [ + "team_member" + ], + "warnings": [] + }, + "team remove-member": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "team_member_remove_input", + "result_input": "team_member_remove_input", + "result": "team_member_mutation", + "statuses": [ + "completed", + "removed", + "started" + ], + "kinds": [ + "team_member" + ], + "warnings": [] + }, + "version": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "version", + "statuses": [], + "kinds": [ + "version" + ], + "warnings": [] + } + } +}