From fb25801f3548478dd4e8ff9e86ed430c6fdada5a Mon Sep 17 00:00:00 2001 From: Erol Ozcan Date: Mon, 6 Jul 2026 19:06:31 +0300 Subject: [PATCH] zoho: preserve root_folder_id on reconnect and allow setting it The Zoho config system ended every interactive create, update and reconnect by calling m.Set("root_folder_id", workspaceID) in the workspace_end state, with fs.ConfigChoose defaulting to the first workspace. Any existing root_folder_id was therefore overwritten and the remote silently repointed to the first workspace root. This matters because reconnect is the documented fix for the 401 INVALID_OAUTHSCOPE download error - tokens issued before the ZohoFiles.files.ALL scope was added lack download access - so users are told to reconnect and then find all subsequent list/copy/sync/delete operations pointed at a different, often shared, workspace. Gate the workspace selection the way the drive backend does for team drives (#5454): if a root_folder_id is already set, ask "Change current root folder id ...?" defaulting to No and keep it; only run workspace selection when it is empty or the user opts in. The token type rewrite still runs on every reconnect so the scope refresh is unaffected. Also expose root_folder_id as a standard advanced option (Sensitive, so config redacted masks it) so it can be set and discovered like on drive/box/onedrive. Fixes #9575 --- backend/zoho/zoho.go | 19 ++++++++ backend/zoho/zoho_internal_test.go | 71 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 backend/zoho/zoho_internal_test.go diff --git a/backend/zoho/zoho.go b/backend/zoho/zoho.go index 4d4f26ac8..092f8c25a 100644 --- a/backend/zoho/zoho.go +++ b/backend/zoho/zoho.go @@ -151,6 +151,20 @@ func init() { } } + // If a root_folder_id is already set (from config, or a previous + // setup) don't overwrite it on update/reconnect unless the user + // asks to, mirroring how the drive backend gates its team drive id. + if rootID, _ := m.Get(configRootID); rootID != "" { + return fs.ConfigConfirm("root_change", false, "config_change_root", fmt.Sprintf("Change current root folder id %q?\n", rootID)) + } + return fs.ConfigGoto("select_edition") + case "root_change": + if config.Result == "false" { + // Keep the existing root_folder_id; the token has already been refreshed. + return nil, nil + } + return fs.ConfigGoto("select_edition") + case "select_edition": _, apiSrv, err := getSrvs() if err != nil { return nil, err @@ -252,6 +266,11 @@ browser.`, Value: "com.au", Help: "Australia", }}, + }, { + Name: "root_folder_id", + Help: "ID of the root folder.\n\nLeave blank normally.\n\nFill in to make rclone use a non root folder as its starting point.", + Advanced: true, + Sensitive: true, }, { Name: "upload_cutoff", Help: "Cutoff for switching to large file upload api (>= 10 MiB).", diff --git a/backend/zoho/zoho_internal_test.go b/backend/zoho/zoho_internal_test.go new file mode 100644 index 000000000..a1e73195b --- /dev/null +++ b/backend/zoho/zoho_internal_test.go @@ -0,0 +1,71 @@ +package zoho + +import ( + "context" + "testing" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/configmap" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConfigRootFolderID drives the interactive Config state machine directly +// and checks that an existing root_folder_id is preserved on update/reconnect: +// workspace selection only runs when the id is empty or the user opts in. The +// seeded token is already the Zoho type and no region service is contacted, so +// these cases make no network calls. +func TestConfigRootFolderID(t *testing.T) { + regInfo := fs.MustFind("zoho") + const rootID = "abc123rootfolderid" + // Already the Zoho custom type, so the "type" state's token rewrite is a + // no-op and nothing is sent to the network. + const token = `{"access_token":"x","token_type":"Zoho-oauthtoken"}` + + newMapper := func(withRoot bool) configmap.Simple { + m := configmap.Simple{"region": "eu", "token": token} + if withRoot { + m[configRootID] = rootID + } + return m + } + + ctx := context.Background() + + t.Run("SetRootAsksBeforeChanging", func(t *testing.T) { + m := newMapper(true) + out, err := regInfo.Config(ctx, "zoho", m, fs.ConfigIn{State: "type"}) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, "root_change", out.State) + require.NotNil(t, out.Option) + got, _ := m.Get(configRootID) + assert.Equal(t, rootID, got, "id must not change before the user answers") + }) + + t.Run("EmptyRootGoesToEdition", func(t *testing.T) { + m := newMapper(false) + out, err := regInfo.Config(ctx, "zoho", m, fs.ConfigIn{State: "type"}) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, "select_edition", out.State) + assert.Nil(t, out.Option, "goto, not a question") + }) + + t.Run("KeepRootOnNo", func(t *testing.T) { + m := newMapper(true) + out, err := regInfo.Config(ctx, "zoho", m, fs.ConfigIn{State: "root_change", Result: "false"}) + require.NoError(t, err) + assert.Nil(t, out, "answering No ends config and keeps the id") + got, _ := m.Get(configRootID) + assert.Equal(t, rootID, got) + }) + + t.Run("ChangeRootOnYes", func(t *testing.T) { + m := newMapper(true) + out, err := regInfo.Config(ctx, "zoho", m, fs.ConfigIn{State: "root_change", Result: "true"}) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, "select_edition", out.State) + }) +}